java-topology/defects/tor/unit/TorRouterlistTest.java
russell@unturf.com 0a580b313d undefect. CWE-407 — 63 sites patched across 27 ecosystems
Authors: russell@unturf.com · brackishbert@gmail.com · foxhop.net · TimeHexOn.com

Patches, unit tests, benchmarks, whitepaper, and outreach briefs.
Public domain — no copyright claimed. Use freely.
2026-03-26 17:11:57 -04:00

269 lines
12 KiB
Java

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<String> requestedFps, List<String> receivedFps) {
// Work on a mutable copy so we can remove entries as they are confirmed.
List<String> 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<String> requestedFps, List<String> receivedFps) {
// Build the "digestset" equivalent: a HashSet for O(1) lookup.
Set<String> fpSet = new HashSet<>(requestedFps);
// Also keep a mutable list for the exact-bookkeeping remove.
List<String> 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<String> requested = new ArrayList<>();
List<String> 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<String> req = (List<String>) f[0];
List<String> rec = (List<String>) 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<String> req = (List<String>) f[0];
List<String> rec = (List<String>) 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<String> req = (List<String>) f[0];
List<String> rec = (List<String>) 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<String> req = (List<String>) f[0];
List<String> rec = (List<String>) 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<String> req = (List<String>) f[0];
List<String> rec = (List<String>) 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.");
}
}