Browse Source

Refactor: Move tests to separate files

Vecna 4 weeks ago
parent
commit
457acc6344

+ 9 - 1803
src/tests.rs

@@ -1,1804 +1,10 @@
-#![allow(non_snake_case)]
-
-use crate::{
-    analysis::{blocked_in, Analyzer},
-    bridge_verification_info::BridgeVerificationInfo,
-    simulation::extra_infos_server,
-    *,
-};
-use lox_library::{
-    bridge_table::{self, BridgeLine, BridgeTable},
-    cred::Lox,
-    proto::*,
-    scalar_u32, BridgeAuth, BridgeDb,
-};
-
-use base64::{engine::general_purpose, Engine as _};
-use curve25519_dalek::{ristretto::RistrettoBasepointTable, Scalar};
-use rand::RngCore;
-use sha1::{Digest, Sha1};
-use std::{
-    collections::{BTreeMap, HashMap, HashSet},
-    sync::{Arc, Mutex},
-    time::Duration,
-};
-use tokio::{spawn, time::sleep};
-use x25519_dalek::{PublicKey, StaticSecret};
-
-struct TestHarness {
-    bdb: BridgeDb,
-    pub ba: BridgeAuth,
-}
-
-impl TestHarness {
-    fn new() -> Self {
-        TestHarness::new_buckets(5, 5)
-    }
-
-    fn new_buckets(num_buckets: u16, hot_spare: u16) -> Self {
-        // Create a BridegDb
-        let mut bdb = BridgeDb::new();
-        // Create a BridgeAuth
-        let mut ba = BridgeAuth::new(bdb.pubkey);
-
-        // Make 3 x num_buckets open invitation bridges, in sets of 3
-        for _ in 0..num_buckets {
-            let bucket = [random(), random(), random()];
-            let _ = ba.add_openinv_bridges(bucket, &mut bdb);
-        }
-        // Add hot_spare more hot spare buckets
-        for _ in 0..hot_spare {
-            let bucket = [random(), random(), random()];
-            let _ = ba.add_spare_bucket(bucket, &mut bdb);
-        }
-        // Create the encrypted bridge table
-        ba.enc_bridge_table();
-
-        Self { bdb, ba }
-    }
-
-    fn advance_days(&mut self, days: u16) {
-        self.ba.advance_days(days);
-    }
-
-    fn get_new_credential(&mut self) -> Lox {
-        let inv = self.bdb.invite().unwrap();
-        let (req, state) = open_invite::request(&inv);
-        let resp = self.ba.handle_open_invite(req).unwrap();
-        let (cred, _bridgeline) =
-            open_invite::handle_response(state, resp, &self.ba.lox_pub).unwrap();
-        cred
-    }
-
-    fn level_up(&mut self, cred: &Lox) -> Lox {
-        let current_level = scalar_u32(&cred.trust_level).unwrap();
-        if current_level == 0 {
-            self.advance_days(trust_promotion::UNTRUSTED_INTERVAL.try_into().unwrap());
-            let (promreq, promstate) =
-                trust_promotion::request(cred, &self.ba.lox_pub, self.ba.today()).unwrap();
-            let promresp = self.ba.handle_trust_promotion(promreq).unwrap();
-            let migcred = trust_promotion::handle_response(promstate, promresp).unwrap();
-            let (migreq, migstate) =
-                migration::request(cred, &migcred, &self.ba.lox_pub, &self.ba.migration_pub)
-                    .unwrap();
-            let migresp = self.ba.handle_migration(migreq).unwrap();
-            let new_cred = migration::handle_response(migstate, migresp, &self.ba.lox_pub).unwrap();
-            new_cred
-        } else {
-            self.advance_days(
-                level_up::LEVEL_INTERVAL[usize::try_from(current_level).unwrap()]
-                    .try_into()
-                    .unwrap(),
-            );
-            let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
-            let encbuckets = self.ba.enc_bridge_table();
-            let bucket =
-                bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
-                    .unwrap();
-            let reachcred = bucket.1.unwrap();
-            let (lvreq, lvstate) = level_up::request(
-                cred,
-                &reachcred,
-                &self.ba.lox_pub,
-                &self.ba.reachability_pub,
-                self.ba.today(),
-            )
-            .unwrap();
-            let lvresp = self.ba.handle_level_up(lvreq).unwrap();
-            let new_cred = level_up::handle_response(lvstate, lvresp, &self.ba.lox_pub).unwrap();
-            new_cred
-        }
-    }
-
-    fn get_bucket(&mut self, cred: &Lox) -> [BridgeLine; bridge_table::MAX_BRIDGES_PER_BUCKET] {
-        let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
-        let encbuckets = self.ba.enc_bridge_table();
-        let bucket =
-            bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
-                .unwrap();
-        bucket.0
-    }
-}
-
-pub fn random() -> BridgeLine {
-    let mut rng = rand::thread_rng();
-    let mut res: BridgeLine = BridgeLine::default();
-    // Pick a random 4-byte address
-    let mut addr: [u8; 4] = [0; 4];
-    rng.fill_bytes(&mut addr);
-    // If the leading byte is 224 or more, that's not a valid IPv4
-    // address.  Choose an IPv6 address instead (but don't worry too
-    // much about it being well formed).
-    if addr[0] >= 224 {
-        rng.fill_bytes(&mut res.addr);
-    } else {
-        // Store an IPv4 address as a v4-mapped IPv6 address
-        res.addr[10] = 255;
-        res.addr[11] = 255;
-        res.addr[12..16].copy_from_slice(&addr);
-    };
-    let ports: [u16; 4] = [443, 4433, 8080, 43079];
-    let portidx = (rng.next_u32() % 4) as usize;
-    res.port = ports[portidx];
-    res.uid_fingerprint = rng.next_u64();
-    rng.fill_bytes(&mut res.fingerprint);
-    let mut cert: [u8; 52] = [0; 52];
-    rng.fill_bytes(&mut cert);
-    let infostr: String = format!(
-        "obfs4 cert={}, iat-mode=0",
-        general_purpose::STANDARD_NO_PAD.encode(cert)
-    );
-    res.info[..infostr.len()].copy_from_slice(infostr.as_bytes());
-    res
-}
-
-#[tokio::test]
-async fn test_download_extra_infos() {
-    let bridge_to_test =
-        array_bytes::hex2array("72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB").unwrap();
-
-    // Open test database
-    let db: Db = sled::open("test_db_dei").unwrap();
-
-    // Delete all data in test DB
-    db.clear().unwrap();
-    assert!(!db.contains_key("bridges").unwrap());
-    assert!(!db.contains_key(bridge_to_test).unwrap());
-
-    // Download and process recent extra-infos files
-    update_extra_infos(
-        &db,
-        "https://collector.torproject.org/recent/bridge-descriptors/extra-infos/",
-    )
-    .await
-    .unwrap();
-
-    // Check that DB contains information on a bridge with high uptime
-    assert!(db.contains_key("bridges").unwrap());
-    let bridges: HashSet<[u8; 20]> =
-        bincode::deserialize(&db.get("bridges").unwrap().unwrap()).unwrap();
-    assert!(bridges.contains(&bridge_to_test));
-    assert!(db.contains_key(bridge_to_test).unwrap());
-    let _bridge_info: BridgeInfo =
-        bincode::deserialize(&db.get(bridge_to_test).unwrap().unwrap()).unwrap();
-}
-
-#[tokio::test]
-async fn test_simulate_extra_infos() {
-    let extra_info_str = r#"@type bridge-extra-info 1.3
-extra-info ElephantBridgeDE2 72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB
-master-key-ed25519 eWxjRwAWW7n8BGG9fNa6rApmBFbe3f0xcD7dqwOICW8
-published 2024-04-06 03:51:04
-transport obfs4
-write-history 2024-04-05 04:55:22 (86400 s) 31665735680,14918491136,15423603712,36168353792,40396827648
-read-history 2024-04-05 04:55:22 (86400 s) 31799622656,15229917184,15479115776,36317251584,40444155904
-ipv6-write-history 2024-04-05 04:55:22 (86400 s) 5972127744,610078720,516897792,327949312,640708608
-ipv6-read-history 2024-04-05 04:55:22 (86400 s) 4156158976,4040448000,2935756800,4263080960,6513532928
-dirreq-write-history 2024-04-05 04:55:22 (86400 s) 625217536,646188032,618014720,584386560,600778752
-dirreq-read-history 2024-04-05 04:55:22 (86400 s) 18816000,19000320,18484224,17364992,18443264
-geoip-db-digest 44073997E1ED63E183B79DE2A1757E9997A834E3
-geoip6-db-digest C0BF46880C6C132D746683279CC90DD4B2D31786
-dirreq-stats-end 2024-04-05 06:51:23 (86400 s)
-dirreq-v3-ips ru=16,au=8,by=8,cn=8,gb=8,ir=8,mt=8,nl=8,pl=8,tn=8,tr=8,us=8
-dirreq-v3-reqs ru=72,gb=64,pl=32,cn=16,ir=16,us=16,au=8,by=8,mt=8,nl=8,tn=8,tr=8
-dirreq-v3-resp ok=216,not-enough-sigs=0,unavailable=0,not-found=0,not-modified=328,busy=0
-dirreq-v3-direct-dl complete=0,timeout=0,running=0
-dirreq-v3-tunneled-dl complete=212,timeout=4,running=0,min=21595,d1=293347,d2=1624137,q1=1911800,d3=2066929,d4=2415000,md=2888500,d6=3264000,d7=3851333,q3=41>
-hidserv-stats-end 2024-04-05 06:51:23 (86400 s)
-hidserv-rend-relayed-cells 7924 delta_f=2048 epsilon=0.30 bin_size=1024
-hidserv-dir-onions-seen -12 delta_f=8 epsilon=0.30 bin_size=8
-hidserv-v3-stats-end 2024-04-05 12:00:00 (86400 s)
-hidserv-rend-v3-relayed-cells -4785 delta_f=2048 epsilon=0.30 bin_size=1024
-hidserv-dir-v3-onions-seen 5 delta_f=8 epsilon=0.30 bin_size=8
-padding-counts 2024-04-05 06:51:42 (86400 s) bin-size=10000 write-drop=0 write-pad=80000 write-total=79980000 read-drop=0 read-pad=1110000 read-total=7989000>
-bridge-stats-end 2024-04-05 06:51:44 (86400 s)
-bridge-ips ru=40,us=32,??=8,au=8,br=8,by=8,cn=8,de=8,eg=8,eu=8,gb=8,ge=8,hr=8,ie=8,ir=8,kp=8,lt=8,mt=8,nl=8,pl=8,ro=8,sg=8,tn=8,tr=8,vn=8
-bridge-ip-versions v4=104,v6=8
-bridge-ip-transports <OR>=56,obfs4=56
-router-digest-sha256 zK0VMl3i0B2eaeQTR03e2hZ0i8ytkuhK/psgD2J1/lQ
-router-digest F30B38390C375E1EE74BFED844177804442569E0"#;
-
-    let extra_info_set = ExtraInfo::parse_file(&extra_info_str);
-    assert_eq!(extra_info_set.len(), 1);
-
-    let extra_info = extra_info_set.iter().next().unwrap().clone();
-
-    let extra_info_str = extra_info.to_string();
-
-    let extra_info_2 = ExtraInfo::parse_file(&extra_info_str)
-        .into_iter()
-        .next()
-        .unwrap()
-        .clone();
-    assert_eq!(extra_info, extra_info_2);
-
-    let bridge_to_test: [u8; 20] =
-        array_bytes::hex2array("72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB").unwrap();
-
-    // Open test database
-    let db: Db = sled::open("test_db_sei").unwrap();
-
-    // Delete all data in test DB
-    db.clear().unwrap();
-    assert!(!db.contains_key("bridges").unwrap());
-    assert!(!db.contains_key(bridge_to_test).unwrap());
-
-    // Start web server
-    spawn(async move {
-        extra_infos_server::server().await;
-    });
-
-    // Give server time to start
-    sleep(Duration::new(1, 0)).await;
-
-    // Update extra-infos (no new data to add)
-    update_extra_infos(&db, "http://localhost:8004/")
-        .await
-        .unwrap();
-
-    // Check that database is still empty
-    assert!(!db.contains_key("bridges").unwrap());
-    assert!(!db.contains_key(bridge_to_test).unwrap());
-
-    // Add our extra-info to the server's records
-    {
-        use hyper::{Body, Client, Method, Request};
-        let client = Client::new();
-        let req = Request::builder()
-            .method(Method::POST)
-            .uri("http://localhost:8004/add".parse::<hyper::Uri>().unwrap())
-            .body(Body::from(serde_json::to_string(&extra_info_set).unwrap()))
-            .unwrap();
-        client.request(req).await.unwrap();
-    }
-
-    // Update extra-infos (add new record)
-    update_extra_infos(&db, "http://localhost:8004/")
-        .await
-        .unwrap();
-
-    // Check that DB now contains information on this bridge
-    assert!(db.contains_key("bridges").unwrap());
-    let bridges: HashSet<[u8; 20]> =
-        bincode::deserialize(&db.get("bridges").unwrap().unwrap()).unwrap();
-    assert!(bridges.contains(&bridge_to_test));
-    assert!(db.contains_key(bridge_to_test).unwrap());
-    let _bridge_info: BridgeInfo =
-        bincode::deserialize(&db.get(bridge_to_test).unwrap().unwrap()).unwrap();
-}
-
-#[test]
-fn test_negative_reports() {
-    let mut th = TestHarness::new();
-
-    // Get new level 1 credential
-    let cred = th.get_new_credential();
-    let cred = th.level_up(&cred);
-
-    let bridges = th.get_bucket(&cred);
-
-    // Create BridgeVerificationInfo for each bridge
-    let mut buckets = HashSet::<Scalar>::new();
-    buckets.insert(cred.bucket);
-    let bridge_info_1 = BridgeVerificationInfo {
-        bridge_line: bridges[0],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-    let bridge_info_2 = BridgeVerificationInfo {
-        bridge_line: bridges[1],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-    let bridge_info_3 = BridgeVerificationInfo {
-        bridge_line: bridges[2],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-
-    // Create reports
-    let report_1 =
-        NegativeReport::from_bridgeline(bridges[0], "ru".to_string(), BridgeDistributor::Lox);
-    let report_2 =
-        NegativeReport::from_lox_bucket(bridges[1].fingerprint, cred.bucket, "ru".to_string());
-    let report_3 =
-        NegativeReport::from_lox_credential(bridges[2].fingerprint, &cred, "ru".to_string());
-
-    // Backdated reports
-    let date = get_date();
-    let mut rng = rand::thread_rng();
-
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-    let report_4 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
-            &bridges[0],
-            date - 1,
-            nonce,
-        )),
-        "ru".to_string(),
-        date - 1,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-    let report_5 = NegativeReport::new(
-        bridges[1].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
-            &bridges[1],
-            date - 2,
-            nonce,
-        )),
-        "ru".to_string(),
-        date - 2,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-    let report_6 = NegativeReport::new(
-        bridges[2].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
-            &bridges[2],
-            date - 3,
-            nonce,
-        )),
-        "ru".to_string(),
-        date - 3,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Verify reports
-    assert!(report_1.verify(&bridge_info_1));
-    assert!(report_2.verify(&bridge_info_2));
-    assert!(report_3.verify(&bridge_info_3));
-    assert!(report_4.verify(&bridge_info_1));
-    assert!(report_5.verify(&bridge_info_2));
-    assert!(report_6.verify(&bridge_info_3));
-
-    // Check that deserialization fails under invalid conditions
-
-    // Date in the future
-    let mut invalid_report_1 =
-        NegativeReport::from_bridgeline(bridges[0], "ru".to_string(), BridgeDistributor::Lox)
-            .to_serializable_report();
-    invalid_report_1.date = invalid_report_1.date + 2;
-
-    // Date too far in past
-    let mut invalid_report_2 =
-        NegativeReport::from_bridgeline(bridges[1], "ru".to_string(), BridgeDistributor::Lox)
-            .to_serializable_report();
-    invalid_report_2.date = invalid_report_2.date - MAX_BACKDATE - 1;
-
-    // Invalid country code
-    let invalid_report_3 =
-        NegativeReport::from_bridgeline(bridges[2], "xx".to_string(), BridgeDistributor::Lox)
-            .to_serializable_report();
-
-    assert!(invalid_report_1.to_report().is_err());
-    assert!(invalid_report_2.to_report().is_err());
-    assert!(invalid_report_3.to_report().is_err());
-
-    // Check that verification fails with incorrect data
-
-    let date = get_date();
-    let mut rng = rand::thread_rng();
-
-    // Incorrect BridgeLine hash
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-    let invalid_report_4 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
-            &BridgeLine::default(),
-            date,
-            nonce,
-        )),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Incorrect bucket hash
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-    let invalid_report_5 = NegativeReport::new(
-        bridges[1].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&Scalar::ZERO, date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    assert!(!invalid_report_4.verify(&bridge_info_1));
-    assert!(!invalid_report_5.verify(&bridge_info_2));
-
-    // Test that reports with duplicate nonces are rejected
-    // (Also test encryption and decryption.)
-
-    // Open test database
-    let db: Db = sled::open("test_db_nr").unwrap();
-
-    // Delete all data in test DB
-    db.clear().unwrap();
-    assert!(!db.contains_key("nrs-to-process").unwrap());
-
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-
-    // A valid report
-    let valid_report_1 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let valid_report_1_copy_1 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let valid_report_1_copy_2 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report which reuses this nonce
-    let invalid_report_1 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // This is the same report
-    assert_eq!(valid_report_1, invalid_report_1);
-
-    // Report which reuses this nonce for a different bridge
-    let invalid_report_2 = NegativeReport::new(
-        bridges[1].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[1], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report which uses this nonce but on a different day
-    let valid_report_2 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
-            &bridges[0],
-            date - 1,
-            nonce,
-        )),
-        "ru".to_string(),
-        date - 1,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report with different nonce
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-
-    let valid_report_3 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let map_key_1 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", valid_report_1.fingerprint),
-        "ru".to_string(),
-        date
-    );
-
-    // Generate key for today
-    let secret = StaticSecret::random_from_rng(&mut rng);
-    let public = PublicKey::from(&secret);
-    let secret_yesterday = StaticSecret::random_from_rng(&mut rng);
-    let public_yesterday = PublicKey::from(&secret_yesterday);
-    assert!(!db.contains_key("nr-keys").unwrap());
-
-    // Fail to add to database because we can't decrypt
-    handle_encrypted_negative_report(&db, valid_report_1_copy_1.encrypt(&public));
-    assert!(!db.contains_key("nrs-to-process").unwrap());
-
-    // Store yesterday's key but not today's
-    let mut nr_keys = BTreeMap::<u32, StaticSecret>::new();
-    nr_keys.insert(date - 1, secret_yesterday);
-    db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap())
-        .unwrap();
-
-    // Fail to add to database because we still can't decrypt
-    handle_encrypted_negative_report(&db, valid_report_1_copy_2.encrypt(&public));
-    assert!(!db.contains_key("nrs-to-process").unwrap());
-
-    // Store today's key
-    nr_keys.insert(date, secret);
-    db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap())
-        .unwrap();
-
-    handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    let map_key_2 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", invalid_report_2.fingerprint),
-        "ru".to_string(),
-        date
-    );
-    handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    assert!(!nrs_to_process.contains_key(&map_key_2));
-
-    let map_key_3 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", valid_report_2.fingerprint),
-        "ru".to_string(),
-        date - 1
-    );
-    handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_3).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 2);
-
-    // Same tests, but use hash of bucket
-
-    // Delete all data in test DB
-    db.clear().unwrap();
-    assert!(!db.contains_key("nrs-to-process").unwrap());
-
-    // Re-generate keys and save in database
-    let public = new_negative_report_key(&db, date).unwrap();
-    let public_yesterday = new_negative_report_key(&db, date - 1).unwrap();
-
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-
-    // A valid report
-    let valid_report_1 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report which reuses this nonce
-    let invalid_report_1 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // This is the same report
-    assert_eq!(valid_report_1, invalid_report_1);
-
-    // Report which reuses this nonce for a different bridge
-    let invalid_report_2 = NegativeReport::new(
-        bridges[1].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report which uses this nonce but on a different day
-    let valid_report_2 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date - 1, nonce)),
-        "ru".to_string(),
-        date - 1,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    // Report with different nonce
-    let mut nonce = [0; 32];
-    rng.fill_bytes(&mut nonce);
-
-    let valid_report_3 = NegativeReport::new(
-        bridges[0].fingerprint,
-        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
-        "ru".to_string(),
-        date,
-        nonce,
-        BridgeDistributor::Lox,
-    );
-
-    let map_key_1 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", valid_report_1.fingerprint),
-        "ru".to_string(),
-        date
-    );
-    handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    let map_key_2 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", invalid_report_2.fingerprint),
-        "ru".to_string(),
-        date
-    );
-    handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    assert!(!nrs_to_process.contains_key(&map_key_2));
-
-    let map_key_3 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", valid_report_2.fingerprint),
-        "ru".to_string(),
-        date - 1
-    );
-    handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_3).unwrap();
-    assert_eq!(negative_reports.len(), 1);
-
-    handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public));
-    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
-        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
-    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(negative_reports.len(), 2);
-}
-
-#[test]
-fn test_positive_reports() {
-    let mut th = TestHarness::new();
-
-    // Get new level 3 credential
-    let cred = th.get_new_credential();
-    let cred = th.level_up(&cred);
-    let cred = th.level_up(&cred);
-    let cred = th.level_up(&cred);
-
-    let bridges = th.get_bucket(&cred);
-
-    // Create BridgeVerificationInfo for each bridge
-    let mut buckets = HashSet::<Scalar>::new();
-    buckets.insert(cred.bucket);
-    let bridge_info_1 = BridgeVerificationInfo {
-        bridge_line: bridges[0],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-    let bridge_info_2 = BridgeVerificationInfo {
-        bridge_line: bridges[1],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-    let bridge_info_3 = BridgeVerificationInfo {
-        bridge_line: bridges[2],
-        buckets: buckets.clone(),
-        pubkey: None,
-    };
-
-    // Create reports
-    let report_1 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-    let report_2 = PositiveReport::from_lox_credential(
-        bridges[1].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-    let report_3 = PositiveReport::from_lox_credential(
-        bridges[2].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-
-    // Compute Htable
-    let H = lox_library::proto::positive_report::compute_H(report_1.date);
-    let Htable = RistrettoBasepointTable::create(&H);
-
-    assert!(report_1.verify(&mut th.ba, &bridge_info_1, &Htable));
-    assert!(report_2.verify(&mut th.ba, &bridge_info_2, &Htable));
-    assert!(report_3.verify(&mut th.ba, &bridge_info_3, &Htable));
-
-    // Check that user cannot use credential for other bridge
-
-    // Get new credential
-    let cred_2 = th.get_new_credential();
-    let bridges_2 = th.get_bucket(&cred_2);
-
-    let mut buckets_2 = HashSet::<Scalar>::new();
-    buckets_2.insert(cred_2.bucket);
-    let bridge_info_4 = BridgeVerificationInfo {
-        bridge_line: bridges_2[0],
-        buckets: buckets_2.clone(),
-        pubkey: None,
-    };
-
-    // Use new credential to create positive report even we don't trust it
-    let invalid_report_1 = PositiveReport::from_lox_credential(
-        bridges_2[0].fingerprint,
-        None,
-        &cred_2,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    );
-
-    // Use first credential for bridge from second bucket
-    let invalid_report_2 = PositiveReport::from_lox_credential(
-        bridges_2[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    );
-
-    // Use second credential for bridge from first bucket
-    let invalid_report_3 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred_2,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    );
-
-    // Check that all of these fail
-    assert!(invalid_report_1.is_err());
-    assert!(!invalid_report_2
-        .unwrap()
-        .verify(&mut th.ba, &bridge_info_4, &Htable));
-    assert!(invalid_report_3.is_err());
-
-    // Check that deserialization fails under invalid conditions
-
-    // Date in the future
-    let mut invalid_report_4 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap()
-    .to_serializable_report();
-    invalid_report_4.date = invalid_report_4.date + 2;
-
-    // Invalid country code
-    let invalid_report_5 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "xx".to_string(),
-    )
-    .unwrap()
-    .to_serializable_report();
-
-    assert!(invalid_report_4.to_report().is_err());
-    assert!(invalid_report_5.to_report().is_err());
-
-    // Test storing to-be-processed positive reports to database
-
-    // Create reports
-    let report_1 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-    let report_2 = PositiveReport::from_lox_credential(
-        bridges[0].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-    let report_3 = PositiveReport::from_lox_credential(
-        bridges[1].fingerprint,
-        None,
-        &cred,
-        &th.ba.lox_pub,
-        "ru".to_string(),
-    )
-    .unwrap();
-
-    // Open test database
-    let db: Db = sled::open("test_db_pr").unwrap();
-
-    // Delete all data in test DB
-    db.clear().unwrap();
-    assert!(!db.contains_key("prs-to-process").unwrap());
-
-    let map_key_1 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", report_1.fingerprint),
-        &report_1.country,
-        &report_1.date
-    );
-    let map_key_2 = format!(
-        "{}_{}_{}",
-        array_bytes::bytes2hex("", report_3.fingerprint),
-        &report_3.country,
-        &report_3.date
-    );
-
-    save_positive_report_to_process(&db, report_1);
-    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
-        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
-    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(positive_reports.len(), 1);
-    assert!(!prs_to_process.contains_key(&map_key_2));
-
-    save_positive_report_to_process(&db, report_2);
-    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
-        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
-    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(positive_reports.len(), 2);
-    assert!(!prs_to_process.contains_key(&map_key_2));
-
-    save_positive_report_to_process(&db, report_3);
-    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
-        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
-    // Check that this has not changed
-    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
-    assert_eq!(positive_reports.len(), 2);
-    // New report added to its own collection
-    let positive_reports = prs_to_process.get(&map_key_2).unwrap();
-    assert_eq!(positive_reports.len(), 1);
-}
-
-#[test]
-fn test_analysis() {
-    // Test stage 1 analysis
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 1 connection, 0 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            8,
-        );
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 0 connections, 0 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            0,
-        );
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 0 connections, 1 negative report
-        // (exceeds scaled threshold)
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        blocking_countries.insert("ru".to_string());
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 1 connection, 1 negative report
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            8,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 8 connections, 2 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            8,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 8 connections, 3 negative reports
-        // (exceeds scaled threshold)
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            8,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            3,
-        );
-        blocking_countries.insert("ru".to_string());
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // 24 connections, 5 negative reports
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            5,
-        );
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // 24 connections, 6 negative reports
-        // (exceeds max threshold)
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            6,
-        );
-        blocking_countries.insert("ru".to_string());
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    // Test stage 2 analysis
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 40 connections, 12 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            40,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            12,
-        );
-        blocking_countries.insert("ru".to_string());
-
-        // This should be blocked because it's different in the bad direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 800 connections, 12 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            800,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            12,
-        );
-        blocking_countries.insert("ru".to_string());
-
-        // The censor artificially inflated bridge stats to prevent detection.
-        // Ensure we still detect the censorship from negative reports.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 0 connections, 0 negative reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            0,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            0,
-        );
-        blocking_countries.insert("ru".to_string());
-
-        // This should be blocked because it's different in the bad direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    // Test stage 3 analysis
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::PositiveReports,
-                date,
-                16 + i % 5,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports, 17 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            17,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report, 100 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            100,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 40 connections, 12 negative reports, 40 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            40,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            12,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            40,
-        );
-        blocking_countries.insert("ru".to_string());
-
-        // This should be blocked because it's different in the bad direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::PositiveReports,
-                date,
-                16 + i % 5,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports, 17 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            17,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report, 85 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            85,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 800 connections, 12 negative reports, 750 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            800,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            12,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            750,
-        );
-
-        blocking_countries.insert("ru".to_string());
-
-        // The censor artificially inflated bridge stats to prevent detection.
-        // Ensure we still detect the censorship from negative reports.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
-
-    {
-        let mut date = get_date();
-
-        // New bridge info
-        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
-
-        bridge_info
-            .info_by_country
-            .insert("ru".to_string(), BridgeCountryInfo::new(date));
-        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
-        let confidence = 0.95;
-
-        let mut blocking_countries = HashSet::<String>::new();
-
-        // No data today
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        for i in 1..30 {
-            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
-            date += 1;
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::BridgeIps,
-                date,
-                8 * (i % 3 + 2),
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::NegativeReports,
-                date,
-                i % 4,
-            );
-            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-                BridgeInfoType::PositiveReports,
-                date,
-                16 + i % 5,
-            );
-            assert_eq!(
-                blocked_in(&analyzer, &bridge_info, confidence, date),
-                blocking_countries
-            );
-        }
-
-        // Data similar to previous days:
-        // 24 connections, 2 negative reports, 17 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            2,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            17,
-        );
-
-        // Should not be blocked because we have similar data.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 104 connections, 1 negative report, 100 positive reports
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            104,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            100,
-        );
-
-        // This should not be blocked even though it's very different because
-        // it's different in the good direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-
-        // Data different from previous days:
-        // 24 connections, 1 negative report, 1 positive report
-        date += 1;
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::BridgeIps,
-            date,
-            24,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::NegativeReports,
-            date,
-            1,
-        );
-        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
-            BridgeInfoType::PositiveReports,
-            date,
-            1,
-        );
-
-        blocking_countries.insert("ru".to_string());
-
-        // This should be blocked because it's different in the bad direction.
-        assert_eq!(
-            blocked_in(&analyzer, &bridge_info, confidence, date),
-            blocking_countries
-        );
-    }
+mod analysis {
+    mod stage_one;
+    mod stage_three;
+    mod stage_two;
+}
+mod extra_infos;
+mod reports {
+    mod negative_reports;
+    mod positive_reports;
 }

+ 189 - 0
src/tests/analysis/stage_one.rs

@@ -0,0 +1,189 @@
+use crate::{analysis::blocked_in, *};
+use std::collections::HashSet;
+
+#[test]
+fn test_stage_1_analysis() {
+    // Test stage 1 analysis
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 1 connection, 0 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            8,
+        );
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 0 connections, 0 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            0,
+        );
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 0 connections, 1 negative report
+        // (exceeds scaled threshold)
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        blocking_countries.insert("ru".to_string());
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 1 connection, 1 negative report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            8,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 8 connections, 2 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            8,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 8 connections, 3 negative reports
+        // (exceeds scaled threshold)
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            8,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            3,
+        );
+        blocking_countries.insert("ru".to_string());
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // 24 connections, 5 negative reports
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            5,
+        );
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // 24 connections, 6 negative reports
+        // (exceeds max threshold)
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            6,
+        );
+        blocking_countries.insert("ru".to_string());
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+}

+ 497 - 0
src/tests/analysis/stage_three.rs

@@ -0,0 +1,497 @@
+use crate::{analysis::blocked_in, *};
+use std::collections::HashSet;
+
+#[tokio::test]
+async fn test_stage_3_analysis() {
+    // Test workaround when computed covariance matrix is not positive definite
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for _ in 1..30 {
+            // 9-16 connections, 1 negative report, 13 positive reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                16,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                1,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::PositiveReports,
+                date,
+                13,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 16 connections, 1 negative report, 12 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            16,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            12,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report, 100 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            100,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 40 connections, 12 negative reports, 40 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            40,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            12,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            40,
+        );
+        blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 2),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::PositiveReports,
+                date,
+                16 + i % 5,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 24 connections, 2 negative reports, 17 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            17,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report, 100 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            100,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 40 connections, 12 negative reports, 40 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            40,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            12,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            40,
+        );
+        blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 2),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::PositiveReports,
+                date,
+                16 + i % 5,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 24 connections, 2 negative reports, 17 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            17,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report, 85 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            85,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 800 connections, 12 negative reports, 750 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            800,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            12,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            750,
+        );
+
+        blocking_countries.insert("ru".to_string());
+
+        // The censor artificially inflated bridge stats to prevent detection.
+        // Ensure we still detect the censorship from negative reports.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // 9-32 connections, 0-3 negative reports, 16-20 positive reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 2),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::PositiveReports,
+                date,
+                16 + i % 5,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 24 connections, 2 negative reports, 17 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            17,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report, 100 positive reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            100,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 24 connections, 1 negative report, 1 positive report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::PositiveReports,
+            date,
+            1,
+        );
+
+        blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+}

+ 374 - 0
src/tests/analysis/stage_two.rs

@@ -0,0 +1,374 @@
+use crate::{analysis::blocked_in, *};
+use std::collections::HashSet;
+
+#[tokio::test]
+async fn test_stage_2_analysis() {
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // 9-32 connections, 0-3 negative reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 2),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 24 connections, 2 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 40 connections, 12 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            40,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            12,
+        );
+        blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    // Test workaround when computed covariance matrix is not positive definite
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for _ in 1..30 {
+            // 1-8 connections, 1 negative report each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8,
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                1,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 8 connections, 1 negative report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            8,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 800 connections, 12 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            800,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            12,
+        );
+        blocking_countries.insert("ru".to_string());
+
+        // The censor artificially inflated bridge stats to prevent detection.
+        // Ensure we still detect the censorship from negative reports.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // 9-32 connections, 0-3 negative reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 2),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data similar to previous days:
+        // 24 connections, 2 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            24,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+
+        // Should not be blocked because we have similar data.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 104 connections, 1 negative report
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            104,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            1,
+        );
+
+        // This should not be blocked even though it's very different because
+        // it's different in the good direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        // Data different from previous days:
+        // 0 connections, 0 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            0,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            0,
+        );
+        //blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+
+    {
+        let mut date = get_date();
+
+        // New bridge info
+        let mut bridge_info = BridgeInfo::new([0; 20], &String::default());
+
+        bridge_info
+            .info_by_country
+            .insert("ru".to_string(), BridgeCountryInfo::new(date));
+        let analyzer = analysis::NormalAnalyzer::new(5, 0.25);
+        let confidence = 0.95;
+
+        let mut blocking_countries = HashSet::<String>::new();
+
+        // No data today
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+
+        for i in 1..30 {
+            // ~96 connections, 0-3 negative reports each day
+            date += 1;
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::BridgeIps,
+                date,
+                8 * (i % 3 + 11),
+            );
+            bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+                BridgeInfoType::NegativeReports,
+                date,
+                i % 4,
+            );
+            assert_eq!(
+                blocked_in(&analyzer, &bridge_info, confidence, date),
+                blocking_countries
+            );
+        }
+
+        // Data different from previous days:
+        // 0 connections, 0 negative reports
+        date += 1;
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::BridgeIps,
+            date,
+            0,
+        );
+        bridge_info.info_by_country.get_mut("ru").unwrap().add_info(
+            BridgeInfoType::NegativeReports,
+            date,
+            2,
+        );
+        blocking_countries.insert("ru".to_string());
+
+        // This should be blocked because it's different in the bad direction.
+        assert_eq!(
+            blocked_in(&analyzer, &bridge_info, confidence, date),
+            blocking_countries
+        );
+    }
+}

+ 141 - 0
src/tests/extra_infos.rs

@@ -0,0 +1,141 @@
+#![allow(non_snake_case)]
+
+use crate::{simulation::extra_infos_server, *};
+
+use std::{collections::HashSet, time::Duration};
+use tokio::{spawn, time::sleep};
+
+#[tokio::test]
+async fn test_download_extra_infos() {
+    let bridge_to_test =
+        array_bytes::hex2array("72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB").unwrap();
+
+    // Open test database
+    let db: Db = sled::open("test_db_dei").unwrap();
+
+    // Delete all data in test DB
+    db.clear().unwrap();
+    assert!(!db.contains_key("bridges").unwrap());
+    assert!(!db.contains_key(bridge_to_test).unwrap());
+
+    // Download and process recent extra-infos files
+    update_extra_infos(
+        &db,
+        "https://collector.torproject.org/recent/bridge-descriptors/extra-infos/",
+    )
+    .await
+    .unwrap();
+
+    // Check that DB contains information on a bridge with high uptime
+    assert!(db.contains_key("bridges").unwrap());
+    let bridges: HashSet<[u8; 20]> =
+        bincode::deserialize(&db.get("bridges").unwrap().unwrap()).unwrap();
+    assert!(bridges.contains(&bridge_to_test));
+    assert!(db.contains_key(bridge_to_test).unwrap());
+    let _bridge_info: BridgeInfo =
+        bincode::deserialize(&db.get(bridge_to_test).unwrap().unwrap()).unwrap();
+}
+
+#[tokio::test]
+async fn test_simulate_extra_infos() {
+    let extra_info_str = r#"@type bridge-extra-info 1.3
+extra-info ElephantBridgeDE2 72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB
+master-key-ed25519 eWxjRwAWW7n8BGG9fNa6rApmBFbe3f0xcD7dqwOICW8
+published 2024-04-06 03:51:04
+transport obfs4
+write-history 2024-04-05 04:55:22 (86400 s) 31665735680,14918491136,15423603712,36168353792,40396827648
+read-history 2024-04-05 04:55:22 (86400 s) 31799622656,15229917184,15479115776,36317251584,40444155904
+ipv6-write-history 2024-04-05 04:55:22 (86400 s) 5972127744,610078720,516897792,327949312,640708608
+ipv6-read-history 2024-04-05 04:55:22 (86400 s) 4156158976,4040448000,2935756800,4263080960,6513532928
+dirreq-write-history 2024-04-05 04:55:22 (86400 s) 625217536,646188032,618014720,584386560,600778752
+dirreq-read-history 2024-04-05 04:55:22 (86400 s) 18816000,19000320,18484224,17364992,18443264
+geoip-db-digest 44073997E1ED63E183B79DE2A1757E9997A834E3
+geoip6-db-digest C0BF46880C6C132D746683279CC90DD4B2D31786
+dirreq-stats-end 2024-04-05 06:51:23 (86400 s)
+dirreq-v3-ips ru=16,au=8,by=8,cn=8,gb=8,ir=8,mt=8,nl=8,pl=8,tn=8,tr=8,us=8
+dirreq-v3-reqs ru=72,gb=64,pl=32,cn=16,ir=16,us=16,au=8,by=8,mt=8,nl=8,tn=8,tr=8
+dirreq-v3-resp ok=216,not-enough-sigs=0,unavailable=0,not-found=0,not-modified=328,busy=0
+dirreq-v3-direct-dl complete=0,timeout=0,running=0
+dirreq-v3-tunneled-dl complete=212,timeout=4,running=0,min=21595,d1=293347,d2=1624137,q1=1911800,d3=2066929,d4=2415000,md=2888500,d6=3264000,d7=3851333,q3=41>
+hidserv-stats-end 2024-04-05 06:51:23 (86400 s)
+hidserv-rend-relayed-cells 7924 delta_f=2048 epsilon=0.30 bin_size=1024
+hidserv-dir-onions-seen -12 delta_f=8 epsilon=0.30 bin_size=8
+hidserv-v3-stats-end 2024-04-05 12:00:00 (86400 s)
+hidserv-rend-v3-relayed-cells -4785 delta_f=2048 epsilon=0.30 bin_size=1024
+hidserv-dir-v3-onions-seen 5 delta_f=8 epsilon=0.30 bin_size=8
+padding-counts 2024-04-05 06:51:42 (86400 s) bin-size=10000 write-drop=0 write-pad=80000 write-total=79980000 read-drop=0 read-pad=1110000 read-total=7989000>
+bridge-stats-end 2024-04-05 06:51:44 (86400 s)
+bridge-ips ru=40,us=32,??=8,au=8,br=8,by=8,cn=8,de=8,eg=8,eu=8,gb=8,ge=8,hr=8,ie=8,ir=8,kp=8,lt=8,mt=8,nl=8,pl=8,ro=8,sg=8,tn=8,tr=8,vn=8
+bridge-ip-versions v4=104,v6=8
+bridge-ip-transports <OR>=56,obfs4=56
+router-digest-sha256 zK0VMl3i0B2eaeQTR03e2hZ0i8ytkuhK/psgD2J1/lQ
+router-digest F30B38390C375E1EE74BFED844177804442569E0"#;
+
+    let extra_info_set = ExtraInfo::parse_file(&extra_info_str);
+    assert_eq!(extra_info_set.len(), 1);
+
+    let extra_info = extra_info_set.iter().next().unwrap().clone();
+
+    let extra_info_str = extra_info.to_string();
+
+    let extra_info_2 = ExtraInfo::parse_file(&extra_info_str)
+        .into_iter()
+        .next()
+        .unwrap()
+        .clone();
+    assert_eq!(extra_info, extra_info_2);
+
+    let bridge_to_test: [u8; 20] =
+        array_bytes::hex2array("72E12B89136B45BBC81D1EF0AC7DDDBB91B148DB").unwrap();
+
+    // Open test database
+    let db: Db = sled::open("test_db_sei").unwrap();
+
+    // Delete all data in test DB
+    db.clear().unwrap();
+    assert!(!db.contains_key("bridges").unwrap());
+    assert!(!db.contains_key(bridge_to_test).unwrap());
+
+    // Start web server
+    spawn(async move {
+        extra_infos_server::server().await;
+    });
+
+    // Give server time to start
+    sleep(Duration::new(1, 0)).await;
+
+    // Update extra-infos (no new data to add)
+    update_extra_infos(&db, "http://localhost:8004/")
+        .await
+        .unwrap();
+
+    // Check that database is still empty
+    assert!(!db.contains_key("bridges").unwrap());
+    assert!(!db.contains_key(bridge_to_test).unwrap());
+
+    // Add our extra-info to the server's records
+    {
+        use hyper::{Body, Client, Method, Request};
+        let client = Client::new();
+        let req = Request::builder()
+            .method(Method::POST)
+            .uri("http://localhost:8004/add".parse::<hyper::Uri>().unwrap())
+            .body(Body::from(serde_json::to_string(&extra_info_set).unwrap()))
+            .unwrap();
+        client.request(req).await.unwrap();
+    }
+
+    // Update extra-infos (add new record)
+    update_extra_infos(&db, "http://localhost:8004/")
+        .await
+        .unwrap();
+
+    // Check that DB now contains information on this bridge
+    assert!(db.contains_key("bridges").unwrap());
+    let bridges: HashSet<[u8; 20]> =
+        bincode::deserialize(&db.get("bridges").unwrap().unwrap()).unwrap();
+    assert!(bridges.contains(&bridge_to_test));
+    assert!(db.contains_key(bridge_to_test).unwrap());
+    let _bridge_info: BridgeInfo =
+        bincode::deserialize(&db.get(bridge_to_test).unwrap().unwrap()).unwrap();
+}

+ 577 - 0
src/tests/reports/negative_reports.rs

@@ -0,0 +1,577 @@
+#![allow(non_snake_case)]
+
+use crate::{bridge_verification_info::BridgeVerificationInfo, *};
+use lox_library::{
+    bridge_table::{self, BridgeLine},
+    cred::Lox,
+    proto::*,
+    scalar_u32, BridgeAuth, BridgeDb,
+};
+
+use base64::{engine::general_purpose, Engine as _};
+use curve25519_dalek::Scalar;
+use rand::RngCore;
+use std::collections::{BTreeMap, HashSet};
+use x25519_dalek::{PublicKey, StaticSecret};
+
+struct TestHarness {
+    bdb: BridgeDb,
+    pub ba: BridgeAuth,
+}
+
+impl TestHarness {
+    fn new() -> Self {
+        TestHarness::new_buckets(5, 5)
+    }
+
+    fn new_buckets(num_buckets: u16, hot_spare: u16) -> Self {
+        // Create a BridegDb
+        let mut bdb = BridgeDb::new();
+        // Create a BridgeAuth
+        let mut ba = BridgeAuth::new(bdb.pubkey);
+
+        // Make 3 x num_buckets open invitation bridges, in sets of 3
+        for _ in 0..num_buckets {
+            let bucket = [random(), random(), random()];
+            let _ = ba.add_openinv_bridges(bucket, &mut bdb);
+        }
+        // Add hot_spare more hot spare buckets
+        for _ in 0..hot_spare {
+            let bucket = [random(), random(), random()];
+            let _ = ba.add_spare_bucket(bucket, &mut bdb);
+        }
+        // Create the encrypted bridge table
+        ba.enc_bridge_table();
+
+        Self { bdb, ba }
+    }
+
+    fn advance_days(&mut self, days: u16) {
+        self.ba.advance_days(days);
+    }
+
+    fn get_new_credential(&mut self) -> Lox {
+        let inv = self.bdb.invite().unwrap();
+        let (req, state) = open_invite::request(&inv);
+        let resp = self.ba.handle_open_invite(req).unwrap();
+        let (cred, _bridgeline) =
+            open_invite::handle_response(state, resp, &self.ba.lox_pub).unwrap();
+        cred
+    }
+
+    fn level_up(&mut self, cred: &Lox) -> Lox {
+        let current_level = scalar_u32(&cred.trust_level).unwrap();
+        if current_level == 0 {
+            self.advance_days(trust_promotion::UNTRUSTED_INTERVAL.try_into().unwrap());
+            let (promreq, promstate) =
+                trust_promotion::request(cred, &self.ba.lox_pub, self.ba.today()).unwrap();
+            let promresp = self.ba.handle_trust_promotion(promreq).unwrap();
+            let migcred = trust_promotion::handle_response(promstate, promresp).unwrap();
+            let (migreq, migstate) =
+                migration::request(cred, &migcred, &self.ba.lox_pub, &self.ba.migration_pub)
+                    .unwrap();
+            let migresp = self.ba.handle_migration(migreq).unwrap();
+            let new_cred = migration::handle_response(migstate, migresp, &self.ba.lox_pub).unwrap();
+            new_cred
+        } else {
+            self.advance_days(
+                level_up::LEVEL_INTERVAL[usize::try_from(current_level).unwrap()]
+                    .try_into()
+                    .unwrap(),
+            );
+            let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
+            let encbuckets = self.ba.enc_bridge_table();
+            let bucket =
+                bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
+                    .unwrap();
+            let reachcred = bucket.1.unwrap();
+            let (lvreq, lvstate) = level_up::request(
+                cred,
+                &reachcred,
+                &self.ba.lox_pub,
+                &self.ba.reachability_pub,
+                self.ba.today(),
+            )
+            .unwrap();
+            let lvresp = self.ba.handle_level_up(lvreq).unwrap();
+            let new_cred = level_up::handle_response(lvstate, lvresp, &self.ba.lox_pub).unwrap();
+            new_cred
+        }
+    }
+
+    fn get_bucket(&mut self, cred: &Lox) -> [BridgeLine; bridge_table::MAX_BRIDGES_PER_BUCKET] {
+        let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
+        let encbuckets = self.ba.enc_bridge_table();
+        let bucket =
+            bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
+                .unwrap();
+        bucket.0
+    }
+}
+
+pub fn random() -> BridgeLine {
+    let mut rng = rand::thread_rng();
+    let mut res: BridgeLine = BridgeLine::default();
+    // Pick a random 4-byte address
+    let mut addr: [u8; 4] = [0; 4];
+    rng.fill_bytes(&mut addr);
+    // If the leading byte is 224 or more, that's not a valid IPv4
+    // address.  Choose an IPv6 address instead (but don't worry too
+    // much about it being well formed).
+    if addr[0] >= 224 {
+        rng.fill_bytes(&mut res.addr);
+    } else {
+        // Store an IPv4 address as a v4-mapped IPv6 address
+        res.addr[10] = 255;
+        res.addr[11] = 255;
+        res.addr[12..16].copy_from_slice(&addr);
+    };
+    let ports: [u16; 4] = [443, 4433, 8080, 43079];
+    let portidx = (rng.next_u32() % 4) as usize;
+    res.port = ports[portidx];
+    res.uid_fingerprint = rng.next_u64();
+    rng.fill_bytes(&mut res.fingerprint);
+    let mut cert: [u8; 52] = [0; 52];
+    rng.fill_bytes(&mut cert);
+    let infostr: String = format!(
+        "obfs4 cert={}, iat-mode=0",
+        general_purpose::STANDARD_NO_PAD.encode(cert)
+    );
+    res.info[..infostr.len()].copy_from_slice(infostr.as_bytes());
+    res
+}
+
+#[test]
+fn test_negative_reports() {
+    let mut th = TestHarness::new();
+
+    // Get new level 1 credential
+    let cred = th.get_new_credential();
+    let cred = th.level_up(&cred);
+
+    let bridges = th.get_bucket(&cred);
+
+    // Create BridgeVerificationInfo for each bridge
+    let mut buckets = HashSet::<Scalar>::new();
+    buckets.insert(cred.bucket);
+    let bridge_info_1 = BridgeVerificationInfo {
+        bridge_line: bridges[0],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+    let bridge_info_2 = BridgeVerificationInfo {
+        bridge_line: bridges[1],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+    let bridge_info_3 = BridgeVerificationInfo {
+        bridge_line: bridges[2],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+
+    // Create reports
+    let report_1 =
+        NegativeReport::from_bridgeline(bridges[0], "ru".to_string(), BridgeDistributor::Lox);
+    let report_2 =
+        NegativeReport::from_lox_bucket(bridges[1].fingerprint, cred.bucket, "ru".to_string());
+    let report_3 =
+        NegativeReport::from_lox_credential(bridges[2].fingerprint, &cred, "ru".to_string());
+
+    // Backdated reports
+    let date = get_date();
+    let mut rng = rand::thread_rng();
+
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+    let report_4 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
+            &bridges[0],
+            date - 1,
+            nonce,
+        )),
+        "ru".to_string(),
+        date - 1,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+    let report_5 = NegativeReport::new(
+        bridges[1].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
+            &bridges[1],
+            date - 2,
+            nonce,
+        )),
+        "ru".to_string(),
+        date - 2,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+    let report_6 = NegativeReport::new(
+        bridges[2].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
+            &bridges[2],
+            date - 3,
+            nonce,
+        )),
+        "ru".to_string(),
+        date - 3,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Verify reports
+    assert!(report_1.verify(&bridge_info_1));
+    assert!(report_2.verify(&bridge_info_2));
+    assert!(report_3.verify(&bridge_info_3));
+    assert!(report_4.verify(&bridge_info_1));
+    assert!(report_5.verify(&bridge_info_2));
+    assert!(report_6.verify(&bridge_info_3));
+
+    // Check that deserialization fails under invalid conditions
+
+    // Date in the future
+    let mut invalid_report_1 =
+        NegativeReport::from_bridgeline(bridges[0], "ru".to_string(), BridgeDistributor::Lox)
+            .to_serializable_report();
+    invalid_report_1.date = invalid_report_1.date + 2;
+
+    // Date too far in past
+    let mut invalid_report_2 =
+        NegativeReport::from_bridgeline(bridges[1], "ru".to_string(), BridgeDistributor::Lox)
+            .to_serializable_report();
+    invalid_report_2.date = invalid_report_2.date - MAX_BACKDATE - 1;
+
+    // Invalid country code
+    let invalid_report_3 =
+        NegativeReport::from_bridgeline(bridges[2], "xx".to_string(), BridgeDistributor::Lox)
+            .to_serializable_report();
+
+    assert!(invalid_report_1.to_report().is_err());
+    assert!(invalid_report_2.to_report().is_err());
+    assert!(invalid_report_3.to_report().is_err());
+
+    // Check that verification fails with incorrect data
+
+    let date = get_date();
+    let mut rng = rand::thread_rng();
+
+    // Incorrect BridgeLine hash
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+    let invalid_report_4 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
+            &BridgeLine::default(),
+            date,
+            nonce,
+        )),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Incorrect bucket hash
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+    let invalid_report_5 = NegativeReport::new(
+        bridges[1].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&Scalar::ZERO, date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    assert!(!invalid_report_4.verify(&bridge_info_1));
+    assert!(!invalid_report_5.verify(&bridge_info_2));
+
+    // Test that reports with duplicate nonces are rejected
+    // (Also test encryption and decryption.)
+
+    // Open test database
+    let db: Db = sled::open("test_db_nr").unwrap();
+
+    // Delete all data in test DB
+    db.clear().unwrap();
+    assert!(!db.contains_key("nrs-to-process").unwrap());
+
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+
+    // A valid report
+    let valid_report_1 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let valid_report_1_copy_1 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let valid_report_1_copy_2 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report which reuses this nonce
+    let invalid_report_1 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // This is the same report
+    assert_eq!(valid_report_1, invalid_report_1);
+
+    // Report which reuses this nonce for a different bridge
+    let invalid_report_2 = NegativeReport::new(
+        bridges[1].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[1], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report which uses this nonce but on a different day
+    let valid_report_2 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(
+            &bridges[0],
+            date - 1,
+            nonce,
+        )),
+        "ru".to_string(),
+        date - 1,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report with different nonce
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+
+    let valid_report_3 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let map_key_1 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", valid_report_1.fingerprint),
+        "ru".to_string(),
+        date
+    );
+
+    // Generate key for today
+    let secret = StaticSecret::random_from_rng(&mut rng);
+    let public = PublicKey::from(&secret);
+    let secret_yesterday = StaticSecret::random_from_rng(&mut rng);
+    let public_yesterday = PublicKey::from(&secret_yesterday);
+    assert!(!db.contains_key("nr-keys").unwrap());
+
+    // Fail to add to database because we can't decrypt
+    handle_encrypted_negative_report(&db, valid_report_1_copy_1.encrypt(&public));
+    assert!(!db.contains_key("nrs-to-process").unwrap());
+
+    // Store yesterday's key but not today's
+    let mut nr_keys = BTreeMap::<u32, StaticSecret>::new();
+    nr_keys.insert(date - 1, secret_yesterday);
+    db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap())
+        .unwrap();
+
+    // Fail to add to database because we still can't decrypt
+    handle_encrypted_negative_report(&db, valid_report_1_copy_2.encrypt(&public));
+    assert!(!db.contains_key("nrs-to-process").unwrap());
+
+    // Store today's key
+    nr_keys.insert(date, secret);
+    db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap())
+        .unwrap();
+
+    handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    let map_key_2 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", invalid_report_2.fingerprint),
+        "ru".to_string(),
+        date
+    );
+    handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    assert!(!nrs_to_process.contains_key(&map_key_2));
+
+    let map_key_3 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", valid_report_2.fingerprint),
+        "ru".to_string(),
+        date - 1
+    );
+    handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_3).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 2);
+
+    // Same tests, but use hash of bucket
+
+    // Delete all data in test DB
+    db.clear().unwrap();
+    assert!(!db.contains_key("nrs-to-process").unwrap());
+
+    // Re-generate keys and save in database
+    let public = new_negative_report_key(&db, date).unwrap();
+    let public_yesterday = new_negative_report_key(&db, date - 1).unwrap();
+
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+
+    // A valid report
+    let valid_report_1 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report which reuses this nonce
+    let invalid_report_1 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // This is the same report
+    assert_eq!(valid_report_1, invalid_report_1);
+
+    // Report which reuses this nonce for a different bridge
+    let invalid_report_2 = NegativeReport::new(
+        bridges[1].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report which uses this nonce but on a different day
+    let valid_report_2 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date - 1, nonce)),
+        "ru".to_string(),
+        date - 1,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    // Report with different nonce
+    let mut nonce = [0; 32];
+    rng.fill_bytes(&mut nonce);
+
+    let valid_report_3 = NegativeReport::new(
+        bridges[0].fingerprint,
+        ProofOfBridgeKnowledge::HashOfBucket(HashOfBucket::new(&cred.bucket, date, nonce)),
+        "ru".to_string(),
+        date,
+        nonce,
+        BridgeDistributor::Lox,
+    );
+
+    let map_key_1 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", valid_report_1.fingerprint),
+        "ru".to_string(),
+        date
+    );
+    handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    let map_key_2 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", invalid_report_2.fingerprint),
+        "ru".to_string(),
+        date
+    );
+    handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    assert!(!nrs_to_process.contains_key(&map_key_2));
+
+    let map_key_3 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", valid_report_2.fingerprint),
+        "ru".to_string(),
+        date - 1
+    );
+    handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_3).unwrap();
+    assert_eq!(negative_reports.len(), 1);
+
+    handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public));
+    let nrs_to_process: BTreeMap<String, Vec<SerializableNegativeReport>> =
+        bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
+    let negative_reports = nrs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(negative_reports.len(), 2);
+}

+ 355 - 0
src/tests/reports/positive_reports.rs

@@ -0,0 +1,355 @@
+#![allow(non_snake_case)]
+
+use crate::{bridge_verification_info::BridgeVerificationInfo, *};
+use lox_library::{
+    bridge_table::{self, BridgeLine},
+    cred::Lox,
+    proto::*,
+    scalar_u32, BridgeAuth, BridgeDb,
+};
+
+use base64::{engine::general_purpose, Engine as _};
+use curve25519_dalek::{ristretto::RistrettoBasepointTable, Scalar};
+use rand::RngCore;
+use std::collections::HashSet;
+
+struct TestHarness {
+    bdb: BridgeDb,
+    pub ba: BridgeAuth,
+}
+
+impl TestHarness {
+    fn new() -> Self {
+        TestHarness::new_buckets(5, 5)
+    }
+
+    fn new_buckets(num_buckets: u16, hot_spare: u16) -> Self {
+        // Create a BridegDb
+        let mut bdb = BridgeDb::new();
+        // Create a BridgeAuth
+        let mut ba = BridgeAuth::new(bdb.pubkey);
+
+        // Make 3 x num_buckets open invitation bridges, in sets of 3
+        for _ in 0..num_buckets {
+            let bucket = [random(), random(), random()];
+            let _ = ba.add_openinv_bridges(bucket, &mut bdb);
+        }
+        // Add hot_spare more hot spare buckets
+        for _ in 0..hot_spare {
+            let bucket = [random(), random(), random()];
+            let _ = ba.add_spare_bucket(bucket, &mut bdb);
+        }
+        // Create the encrypted bridge table
+        ba.enc_bridge_table();
+
+        Self { bdb, ba }
+    }
+
+    fn advance_days(&mut self, days: u16) {
+        self.ba.advance_days(days);
+    }
+
+    fn get_new_credential(&mut self) -> Lox {
+        let inv = self.bdb.invite().unwrap();
+        let (req, state) = open_invite::request(&inv);
+        let resp = self.ba.handle_open_invite(req).unwrap();
+        let (cred, _bridgeline) =
+            open_invite::handle_response(state, resp, &self.ba.lox_pub).unwrap();
+        cred
+    }
+
+    fn level_up(&mut self, cred: &Lox) -> Lox {
+        let current_level = scalar_u32(&cred.trust_level).unwrap();
+        if current_level == 0 {
+            self.advance_days(trust_promotion::UNTRUSTED_INTERVAL.try_into().unwrap());
+            let (promreq, promstate) =
+                trust_promotion::request(cred, &self.ba.lox_pub, self.ba.today()).unwrap();
+            let promresp = self.ba.handle_trust_promotion(promreq).unwrap();
+            let migcred = trust_promotion::handle_response(promstate, promresp).unwrap();
+            let (migreq, migstate) =
+                migration::request(cred, &migcred, &self.ba.lox_pub, &self.ba.migration_pub)
+                    .unwrap();
+            let migresp = self.ba.handle_migration(migreq).unwrap();
+            let new_cred = migration::handle_response(migstate, migresp, &self.ba.lox_pub).unwrap();
+            new_cred
+        } else {
+            self.advance_days(
+                level_up::LEVEL_INTERVAL[usize::try_from(current_level).unwrap()]
+                    .try_into()
+                    .unwrap(),
+            );
+            let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
+            let encbuckets = self.ba.enc_bridge_table();
+            let bucket =
+                bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
+                    .unwrap();
+            let reachcred = bucket.1.unwrap();
+            let (lvreq, lvstate) = level_up::request(
+                cred,
+                &reachcred,
+                &self.ba.lox_pub,
+                &self.ba.reachability_pub,
+                self.ba.today(),
+            )
+            .unwrap();
+            let lvresp = self.ba.handle_level_up(lvreq).unwrap();
+            let new_cred = level_up::handle_response(lvstate, lvresp, &self.ba.lox_pub).unwrap();
+            new_cred
+        }
+    }
+
+    fn get_bucket(&mut self, cred: &Lox) -> [BridgeLine; bridge_table::MAX_BRIDGES_PER_BUCKET] {
+        let (id, key) = bridge_table::from_scalar(cred.bucket).unwrap();
+        let encbuckets = self.ba.enc_bridge_table();
+        let bucket =
+            bridge_table::BridgeTable::decrypt_bucket(id, &key, encbuckets.get(&id).unwrap())
+                .unwrap();
+        bucket.0
+    }
+}
+
+pub fn random() -> BridgeLine {
+    let mut rng = rand::thread_rng();
+    let mut res: BridgeLine = BridgeLine::default();
+    // Pick a random 4-byte address
+    let mut addr: [u8; 4] = [0; 4];
+    rng.fill_bytes(&mut addr);
+    // If the leading byte is 224 or more, that's not a valid IPv4
+    // address.  Choose an IPv6 address instead (but don't worry too
+    // much about it being well formed).
+    if addr[0] >= 224 {
+        rng.fill_bytes(&mut res.addr);
+    } else {
+        // Store an IPv4 address as a v4-mapped IPv6 address
+        res.addr[10] = 255;
+        res.addr[11] = 255;
+        res.addr[12..16].copy_from_slice(&addr);
+    };
+    let ports: [u16; 4] = [443, 4433, 8080, 43079];
+    let portidx = (rng.next_u32() % 4) as usize;
+    res.port = ports[portidx];
+    res.uid_fingerprint = rng.next_u64();
+    rng.fill_bytes(&mut res.fingerprint);
+    let mut cert: [u8; 52] = [0; 52];
+    rng.fill_bytes(&mut cert);
+    let infostr: String = format!(
+        "obfs4 cert={}, iat-mode=0",
+        general_purpose::STANDARD_NO_PAD.encode(cert)
+    );
+    res.info[..infostr.len()].copy_from_slice(infostr.as_bytes());
+    res
+}
+
+#[test]
+fn test_positive_reports() {
+    let mut th = TestHarness::new();
+
+    // Get new level 3 credential
+    let cred = th.get_new_credential();
+    let cred = th.level_up(&cred);
+    let cred = th.level_up(&cred);
+    let cred = th.level_up(&cred);
+
+    let bridges = th.get_bucket(&cred);
+
+    // Create BridgeVerificationInfo for each bridge
+    let mut buckets = HashSet::<Scalar>::new();
+    buckets.insert(cred.bucket);
+    let bridge_info_1 = BridgeVerificationInfo {
+        bridge_line: bridges[0],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+    let bridge_info_2 = BridgeVerificationInfo {
+        bridge_line: bridges[1],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+    let bridge_info_3 = BridgeVerificationInfo {
+        bridge_line: bridges[2],
+        buckets: buckets.clone(),
+        pubkey: None,
+    };
+
+    // Create reports
+    let report_1 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+    let report_2 = PositiveReport::from_lox_credential(
+        bridges[1].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+    let report_3 = PositiveReport::from_lox_credential(
+        bridges[2].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+
+    // Compute Htable
+    let H = lox_library::proto::positive_report::compute_H(report_1.date);
+    let Htable = RistrettoBasepointTable::create(&H);
+
+    assert!(report_1.verify(&mut th.ba, &bridge_info_1, &Htable));
+    assert!(report_2.verify(&mut th.ba, &bridge_info_2, &Htable));
+    assert!(report_3.verify(&mut th.ba, &bridge_info_3, &Htable));
+
+    // Check that user cannot use credential for other bridge
+
+    // Get new credential
+    let cred_2 = th.get_new_credential();
+    let bridges_2 = th.get_bucket(&cred_2);
+
+    let mut buckets_2 = HashSet::<Scalar>::new();
+    buckets_2.insert(cred_2.bucket);
+    let bridge_info_4 = BridgeVerificationInfo {
+        bridge_line: bridges_2[0],
+        buckets: buckets_2.clone(),
+        pubkey: None,
+    };
+
+    // Use new credential to create positive report even we don't trust it
+    let invalid_report_1 = PositiveReport::from_lox_credential(
+        bridges_2[0].fingerprint,
+        None,
+        &cred_2,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    );
+
+    // Use first credential for bridge from second bucket
+    let invalid_report_2 = PositiveReport::from_lox_credential(
+        bridges_2[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    );
+
+    // Use second credential for bridge from first bucket
+    let invalid_report_3 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred_2,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    );
+
+    // Check that all of these fail
+    assert!(invalid_report_1.is_err());
+    assert!(!invalid_report_2
+        .unwrap()
+        .verify(&mut th.ba, &bridge_info_4, &Htable));
+    assert!(invalid_report_3.is_err());
+
+    // Check that deserialization fails under invalid conditions
+
+    // Date in the future
+    let mut invalid_report_4 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap()
+    .to_serializable_report();
+    invalid_report_4.date = invalid_report_4.date + 2;
+
+    // Invalid country code
+    let invalid_report_5 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "xx".to_string(),
+    )
+    .unwrap()
+    .to_serializable_report();
+
+    assert!(invalid_report_4.to_report().is_err());
+    assert!(invalid_report_5.to_report().is_err());
+
+    // Test storing to-be-processed positive reports to database
+
+    // Create reports
+    let report_1 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+    let report_2 = PositiveReport::from_lox_credential(
+        bridges[0].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+    let report_3 = PositiveReport::from_lox_credential(
+        bridges[1].fingerprint,
+        None,
+        &cred,
+        &th.ba.lox_pub,
+        "ru".to_string(),
+    )
+    .unwrap();
+
+    // Open test database
+    let db: Db = sled::open("test_db_pr").unwrap();
+
+    // Delete all data in test DB
+    db.clear().unwrap();
+    assert!(!db.contains_key("prs-to-process").unwrap());
+
+    let map_key_1 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", report_1.fingerprint),
+        &report_1.country,
+        &report_1.date
+    );
+    let map_key_2 = format!(
+        "{}_{}_{}",
+        array_bytes::bytes2hex("", report_3.fingerprint),
+        &report_3.country,
+        &report_3.date
+    );
+
+    save_positive_report_to_process(&db, report_1);
+    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
+        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
+    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(positive_reports.len(), 1);
+    assert!(!prs_to_process.contains_key(&map_key_2));
+
+    save_positive_report_to_process(&db, report_2);
+    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
+        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
+    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(positive_reports.len(), 2);
+    assert!(!prs_to_process.contains_key(&map_key_2));
+
+    save_positive_report_to_process(&db, report_3);
+    let prs_to_process: BTreeMap<String, Vec<SerializablePositiveReport>> =
+        bincode::deserialize(&db.get("prs-to-process").unwrap().unwrap()).unwrap();
+    // Check that this has not changed
+    let positive_reports = prs_to_process.get(&map_key_1).unwrap();
+    assert_eq!(positive_reports.len(), 2);
+    // New report added to its own collection
+    let positive_reports = prs_to_process.get(&map_key_2).unwrap();
+    assert_eq!(positive_reports.len(), 1);
+}