package unit; import java.util.*; /** * Unit test for tor-0001: router_load_routers_from_string() CWE-407. * * Defect: router_load_routers_from_string() (routerlist.c:2179) calls * smartlist_contains_string(requested_fingerprints, fp) for every * descriptor received in a batch. smartlist_contains_string() is a * linear scan — O(R) per call, O(R²) total across R descriptors when * the request list starts at size R. * * Fix: Before the descriptor loop build a digestset_t (Tor's bloom-filter * hash set) from the requested fingerprints. Use * digestset_probably_contains() — O(1) — as a fast-path guard. The * existing smartlist_string_remove() call is kept for exact * bookkeeping on confirmed hits. * * Model: * DefectiveLookup — ArrayList.contains() on hex strings (O(R) per check) * FixedLookup — HashSet.contains() on hex strings (O(1) per check) * * The test simulates the fingerprint-matching loop from * router_load_routers_from_string(): * for each descriptor received: * encode its digest as a hex fingerprint * check if the fingerprint is in the requested set * if yes → remove from requested set (confirmed) * if no → drop (unexpected descriptor) * * Measurement: count element-level string comparisons in the membership check. */ public class TorRouterlistTest { static final int HEX_DIGEST_LEN = 40; // SHA-1, 20 bytes → 40 hex chars // ── Fingerprint generation helpers ─────────────────────────────────────── /** Produce a deterministic 40-char uppercase hex fingerprint from index. */ static String makeFp(int index) { return String.format("%040X", (long) index); } // ── Result ─────────────────────────────────────────────────────────────── public static class Result { final int accepted; // descriptors that matched the requested set final int dropped; // descriptors that were not in the requested set public final long comparisons; Result(int accepted, int dropped, long comparisons) { this.accepted = accepted; this.dropped = dropped; this.comparisons = comparisons; } } // ── DEFECTIVE: smartlist_contains_string → ArrayList.contains() ────────── // // Mirrors the actual C code: // if (smartlist_contains_string(requested_fingerprints, fp)) // smartlist_string_remove(requested_fingerprints, fp); // else { warn; drop; } // // ArrayList.contains() scans the entire list — O(R) per descriptor. // With R descriptors and R initial fingerprints the list shrinks by 1 // on each hit, giving O(R + (R-1) + … + 1) = O(R²/2) comparisons. public static Result processDefective(List requestedFps, List receivedFps) { // Work on a mutable copy so we can remove entries as they are confirmed. List remaining = new ArrayList<>(requestedFps); long comparisons = 0; int accepted = 0, dropped = 0; for (String fp : receivedFps) { // smartlist_contains_string: scan the entire remaining list boolean found = false; for (String req : remaining) { comparisons++; if (req.equals(fp)) { found = true; break; } } if (found) { remaining.remove(fp); // smartlist_string_remove accepted++; } else { dropped++; } } return new Result(accepted, dropped, comparisons); } // ── FIXED: digestset_probably_contains → HashSet.contains() ───────────── // // Mirrors the patched C code: // pre-build: digestset_t *fp_set = digestset_new(R) // for each hex_fp in requested_fingerprints: // base16_decode + digestset_add // // per descriptor: // if (digestset_probably_contains(fp_set, ri_digest) && // smartlist_contains_string(requested_fingerprints, fp)) // smartlist_string_remove(requested_fingerprints, fp); // else { warn; drop; } // // HashSet.contains() is O(1). We still do the smartlist_string_remove // (modelled as List.remove here) on hits — that's the exact bookkeeping // path. The O(R) remove is not in the hot comparison path; what matters // is that the membership test is O(1). public static Result processFixed(List requestedFps, List receivedFps) { // Build the "digestset" equivalent: a HashSet for O(1) lookup. Set fpSet = new HashSet<>(requestedFps); // Also keep a mutable list for the exact-bookkeeping remove. List remaining = new ArrayList<>(requestedFps); long comparisons = 0; int accepted = 0, dropped = 0; for (String fp : receivedFps) { comparisons++; // O(1) hash lookup (digestset_probably_contains) if (fpSet.contains(fp)) { // Bloom filter positive → confirm with exact lookup (no false negatives // in our model, so this always succeeds when fpSet says yes). remaining.remove(fp); // smartlist_string_remove — bookkeeping only fpSet.remove(fp); // keep digestset consistent after removal accepted++; } else { dropped++; } } return new Result(accepted, dropped, comparisons); } // ── Build test fixtures ────────────────────────────────────────────────── /** * Build a list of R fingerprints for the requested set. * Build a list of R matching descriptors in REVERSE order relative to the * requested list so that each smartlist_contains_string() scan must walk * the entire remaining list before finding a match — the true O(R²) case. * In production, descriptor arrival order is not controlled by the local * node; reverse order is a realistic worst case (e.g. responses from a * relay that sends newest descriptors first). * Optionally append extra_unexpected unexpected fps to receivedFps. */ public static Object[] buildFixtures(int r, int extraUnexpected) { List requested = new ArrayList<>(); List received = new ArrayList<>(); for (int i = 0; i < r; i++) { requested.add(makeFp(i)); } // Received in reverse order — forces scan to the end of remaining list // on every hit, maximising comparison count. for (int i = r - 1; i >= 0; i--) { received.add(makeFp(i)); } // Unexpected descriptors — not in the request list for (int i = 0; i < extraUnexpected; i++) { received.add(makeFp(i + 100_000)); } return new Object[]{requested, received}; } // ── Tests ──────────────────────────────────────────────────────────────── @SuppressWarnings("unchecked") static void testCorrectnessMatch() { Object[] f = buildFixtures(40, 5); List req = (List) f[0]; List rec = (List) f[1]; Result def = processDefective(new ArrayList<>(req), new ArrayList<>(rec)); Result fix = processFixed( new ArrayList<>(req), new ArrayList<>(rec)); assert def.accepted == fix.accepted : "accepted count must match; defective=" + def.accepted + " fixed=" + fix.accepted; assert def.dropped == fix.dropped : "dropped count must match; defective=" + def.dropped + " fixed=" + fix.dropped; assert def.accepted == 40 : "all 40 matching descriptors must be accepted; got " + def.accepted; assert def.dropped == 5 : "5 unexpected descriptors must be dropped; got " + def.dropped; System.out.println("PASS testCorrectnessMatch"); } @SuppressWarnings("unchecked") static void testDefectiveGrowsQuadratically() { long prev = -1; for (int r : new int[]{50, 100, 200}) { Object[] f = buildFixtures(r, 0); List req = (List) f[0]; List rec = (List) f[1]; long c = processDefective(req, rec).comparisons; if (prev > 0) { double ratio = (double) c / prev; assert ratio > 2.5 : "defective comparisons should grow >2.5x when R doubles; " + "got ratio=" + ratio + " (prev=" + prev + " curr=" + c + ")"; } prev = c; } System.out.println("PASS testDefectiveGrowsQuadratically"); } @SuppressWarnings("unchecked") static void testFixedGrowsLinearly() { // Fixed: exactly 1 comparison per descriptor (the O(1) hash lookup). for (int r : new int[]{50, 100, 200}) { Object[] f = buildFixtures(r, 0); List req = (List) f[0]; List rec = (List) f[1]; long c = processFixed(req, rec).comparisons; assert c == r : "fixed must make exactly R comparisons (one per descriptor); " + "got c=" + c + " for R=" + r; } System.out.println("PASS testFixedGrowsLinearly"); } @SuppressWarnings("unchecked") static void testRatioAtScaleIsLarge() { // At R=400 the defective path does ~80 000 comparisons; fixed does 400. int r = 400; Object[] f = buildFixtures(r, 0); List req = (List) f[0]; List rec = (List) f[1]; long defC = processDefective(new ArrayList<>(req), new ArrayList<>(rec)).comparisons; long fixC = processFixed( new ArrayList<>(req), new ArrayList<>(rec)).comparisons; double ratio = (double) defC / fixC; assert ratio > 10 : "at R=400, defective should be >10x worse; ratio=" + ratio + " (defective=" + defC + " fixed=" + fixC + ")"; System.out.printf( "PASS testRatioAtScaleIsLarge (defective=%d, fixed=%d, ratio=%.1fx)%n", defC, fixC, ratio); } @SuppressWarnings("unchecked") static void testUnexpectedDescriptorsDropped() { // Descriptors not in the request list must always be dropped, regardless // of which implementation handles the lookup. int r = 30, extra = 10; Object[] f = buildFixtures(r, extra); List req = (List) f[0]; List rec = (List) f[1]; Result def = processDefective(new ArrayList<>(req), new ArrayList<>(rec)); Result fix = processFixed( new ArrayList<>(req), new ArrayList<>(rec)); assert def.dropped == extra : "defective must drop all " + extra + " unexpected; got " + def.dropped; assert fix.dropped == extra : "fixed must drop all " + extra + " unexpected; got " + fix.dropped; System.out.println("PASS testUnexpectedDescriptorsDropped"); } public static void main(String[] args) { testCorrectnessMatch(); testDefectiveGrowsQuadratically(); testFixedGrowsLinearly(); testRatioAtScaleIsLarge(); testUnexpectedDescriptorsDropped(); System.out.println("All tor-0001 tests passed."); } }