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.
269 lines
12 KiB
Java
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.");
|
|
}
|
|
}
|