diff --git a/SCAN-TODO.md b/SCAN-TODO.md index d43ab535b..4830aa538 100644 --- a/SCAN-TODO.md +++ b/SCAN-TODO.md @@ -35,7 +35,7 @@ Rule: clone, scan, delete clone after. Keep disk under 90%. - [x] Pidgin/Finch (C, chat client) — pidgin-0001 CWE-407 499.5x, pidgin-0002 CWE-312 - [ ] HexChat (C, IRC client) -- [ ] Evolution (C, email/calendar) +- [x] Evolution (C, email/calendar) — evolution-0002 MOAD-0001 CWE-407 search_hit_cache g_slist_find_custom O(N^2) 433x at N=1000; MOADs 0002/0003/0004/0005 CLEAN - [x] Thunderbird (C++, email) — see Priority 1 entry above; 8 defects total - [x] Calibre (deeper, Python ebook manager) — calibre-0003 MOAD-0001 CWE-407 depth_first flat.index() O(L*F) 38x at F=500; calibre-0004 MOAD-0001 CWE-407 HTMLFile.find_links list dedup O(L^2) 124x at L=1000; MOADs 0002/0003/0004/0005 CLEAN - [ ] Okular/Evince/Zathura (PDF viewers) diff --git a/defects/evolution-0002/TICKET.md b/defects/evolution-0002/TICKET.md new file mode 100644 index 000000000..f8e43a80d --- /dev/null +++ b/defects/evolution-0002/TICKET.md @@ -0,0 +1,61 @@ +# evolution-0002: Calendar Search Hit Cache Dedup O(N^2) in cal_searching_got_instance_cb + +## Target +GNOME Evolution email/calendar client + +## Severity +MEDIUM + +## MOAD +0001 (CWE-407: Inefficient Algorithmic Complexity) + +## Location +`src/modules/calendar/e-cal-shell-view-private.c` — `cal_searching_got_instance_cb()` + +## Description +`cal_searching_got_instance_cb()` is the per-instance callback for Evolution's +calendar "next/previous occurrence" search. It is invoked once per calendar +event instance as `e_cal_client_generate_instances_for_object()` expands +recurring events over a search window. + +Inside the callback, each new instance is deduplicated against +`priv->search_hit_cache` (a GSList of `time_t *`) using `g_slist_find_custom()`. +As instances accumulate the list grows, so the I-th instance check scans up to +I-1 entries: O(1) + O(2) + ... + O(N-1) = O(N^2/2) total comparisons. + +A search over multiple subscribed calendars each containing long-running weekly +recurring events (e.g., a team standup repeated since 2020) easily reaches +hundreds to thousands of instances per search invocation. Navigation +("next occurrence") triggers this search repeatedly. + +## Fix +Add a parallel `GHashTable *search_hit_cache_set` (keyed on `time_t` cast to +`gpointer`) to `ECalShellViewPrivate`. Membership check in +`cal_searching_got_instance_cb()` becomes an O(1) `g_hash_table_contains()`. +Clear and destroy the hash table wherever the GSList is freed. + +## Speedup +| N instances | Ops defective | Ops fixed | Ratio | +|-------------|---------------|-----------|-------| +| 50 | 1225 | 50 | 24.5x | +| 100 | 4950 | 100 | 49.5x | +| 200 | 19900 | 200 | 99.5x | +| 500 | 124750 | 500 | 249.5x | +| 1000 | 499500 | 1000 | 499.5x | + +## MOAD-0002 (Intertangle) +See evolution-0001 SCAN-NOTES.md — `mail-send-recv.c` static globals +(`send_data`, `glob_ongoing_downsyncs`) are shared mutable state with no mutex. +Architectural finding, not patched here. + +## MOAD-0003 +CLEAN. No ThreadLocal or GPrivate request-scoped identity leakage beyond +what was identified in evolution-0001 scan. + +## MOAD-0004 +CLEAN. No credential values (passwords, tokens) logged verbatim. Debug +messages reference password operations by description only. + +## MOAD-0005 +CLEAN. Evolution uses GLib main loop (single UI thread). No concurrent +cache-get+put race conditions. diff --git a/defects/evolution-0002/patch/evolution-0002.patch b/defects/evolution-0002/patch/evolution-0002.patch new file mode 100644 index 000000000..9bce0dd95 --- /dev/null +++ b/defects/evolution-0002/patch/evolution-0002.patch @@ -0,0 +1,53 @@ +# UNDF: TBD +--- a/src/modules/calendar/e-cal-shell-view-private.h ++++ b/src/modules/calendar/e-cal-shell-view-private.h +@@ -113,7 +113,8 @@ struct _ECalShellViewPrivate { + gint search_direction; /* negative value is backward, positive is forward, zero is error; in days */ + GSList *search_hit_cache; /* pointers on time_t for matched events */ ++ GHashTable *search_hit_cache_set; /* time_t values as gpointer keys for O(1) dedup */ + + /* Event/Task/Memo transferring */ + gpointer transfer_alert; /* weak pointer to EAlert * */ +--- a/src/modules/calendar/e-cal-shell-view-private.c ++++ b/src/modules/calendar/e-cal-shell-view-private.c +@@ -878,10 +878,14 @@ cal_searching_got_instance_cb (ICalComponent *icomp, + priv = gid->cal_shell_view->priv; + value = g_new (time_t, 1); + *value = start; +- if (!g_slist_find_custom (priv->search_hit_cache, value, cal_time_t_ptr_compare)) ++ if (!priv->search_hit_cache_set) ++ priv->search_hit_cache_set = g_hash_table_new (g_direct_hash, g_direct_equal); ++ if (!g_hash_table_contains (priv->search_hit_cache_set, GINT_TO_POINTER ((gint) start))) { ++ g_hash_table_add (priv->search_hit_cache_set, GINT_TO_POINTER ((gint) start)); + priv->search_hit_cache = g_slist_append (priv->search_hit_cache, value); +- else ++ } else { + g_free (value); ++ } + + return TRUE; + } +@@ -1271,6 +1275,10 @@ e_cal_shell_view_search_start (ECalShellView *cal_shell_view, + if (priv->search_hit_cache) { + g_slist_free_full (priv->search_hit_cache, g_free); + priv->search_hit_cache = NULL; + } ++ if (priv->search_hit_cache_set) { ++ g_hash_table_destroy (priv->search_hit_cache_set); ++ priv->search_hit_cache_set = NULL; ++ } + + cal_iterate_searching (cal_shell_view); + } +@@ -1300,6 +1308,10 @@ e_cal_shell_view_search_stop (ECalShellView *cal_shell_view) + if (priv->search_hit_cache) { + g_slist_free_full (priv->search_hit_cache, g_free); + priv->search_hit_cache = NULL; + } ++ if (priv->search_hit_cache_set) { ++ g_hash_table_destroy (priv->search_hit_cache_set); ++ priv->search_hit_cache_set = NULL; ++ } + + priv->search_direction = 0; + } diff --git a/defects/evolution-0002/test/test_evolution_0002.py b/defects/evolution-0002/test/test_evolution_0002.py new file mode 100644 index 000000000..2eb2c1e0e --- /dev/null +++ b/defects/evolution-0002/test/test_evolution_0002.py @@ -0,0 +1,130 @@ +""" +Unit test for evolution-0002: Calendar search_hit_cache dedup O(N^2) fix. + +Simulates cal_searching_got_instance_cb() collecting unique time_t values +into a dedup'd cache. Measures op-count for defective (GSList linear scan) +vs fixed (GHashTable O(1)) implementations. + +Asserts speedup > 3x at N=100 and N=1000. +""" +import time +import sys + +PASS = True + + +def collect_instances_defective(instances): + """ + Defective: linear scan for each new instance. + Equivalent to g_slist_find_custom on a growing GSList. + Returns (cache_list, op_count). + """ + cache = [] + ops = 0 + for val in instances: + # Scan entire cache for duplicate — O(k) per insert + found = False + for c in cache: + ops += 1 + if c == val: + found = True + break + if not found: + cache.append(val) + return cache, ops + + +def collect_instances_fixed(instances): + """ + Fixed: O(1) hash set membership check. + Equivalent to g_hash_table_contains before g_slist_append. + Returns (cache_list, op_count). + """ + cache = [] + cache_set = set() + ops = 0 + for val in instances: + ops += 1 # One hash lookup per insert + if val not in cache_set: + cache_set.add(val) + cache.append(val) + return cache, ops + + +def run_test(label, n_instances, expected_speedup): + global PASS + + # Generate N unique timestamps (weekly recurring event instances) + # Mix in some duplicates: same event across two calendars = same timestamp + unique = list(range(n_instances)) + # 20% duplicates simulating overlapping calendar subscriptions + duplicates = unique[: n_instances // 5] + instances = unique + duplicates + + # Shuffle deterministically for reproducibility + # (keep order as-is; order doesn't change big-O result) + + _, ops_defective = collect_instances_defective(instances) + cache_fixed, ops_fixed = collect_instances_fixed(instances) + + ratio = ops_defective / max(ops_fixed, 1) + + status = "PASS" if ratio >= expected_speedup else "FAIL" + if status == "FAIL": + PASS = False + + print( + f"[{status}] {label}: N={n_instances} | " + f"defective={ops_defective} ops | fixed={ops_fixed} ops | ratio={ratio:.1f}x " + f"(expected >= {expected_speedup}x)" + ) + + # Verify correctness: both produce same unique set + cache_defective, _ = collect_instances_defective(instances) + if sorted(cache_defective) != sorted(cache_fixed): + print(f" [FAIL] correctness: cache contents differ!") + PASS = False + else: + print(f" correctness: caches match ({len(cache_fixed)} unique timestamps)") + + +def run_wall_time_test(label, n_instances): + """Wall-clock speedup check for large N.""" + unique = list(range(n_instances)) + duplicates = unique[: n_instances // 5] + instances = unique + duplicates + + t0 = time.perf_counter() + collect_instances_defective(instances) + t1 = time.perf_counter() + collect_instances_fixed(instances) + t2 = time.perf_counter() + + defective_ms = (t1 - t0) * 1000 + fixed_ms = (t2 - t1) * 1000 + ratio = defective_ms / max(fixed_ms, 1e-9) + print( + f" wall-clock {label}: defective={defective_ms:.2f}ms fixed={fixed_ms:.2f}ms ratio={ratio:.1f}x" + ) + + +if __name__ == "__main__": + print("evolution-0002: Calendar search_hit_cache dedup O(N^2) fix") + print("=" * 65) + + run_test("N=50 op-count", 50, 3.0) + run_test("N=100 op-count", 100, 10.0) + run_test("N=500 op-count", 500, 50.0) + run_test("N=1000 op-count", 1000, 100.0) + + print() + run_wall_time_test("N=1000 wall-clock", 1000) + run_wall_time_test("N=5000 wall-clock", 5000) + + print() + if PASS: + print("ALL TESTS PASSED") + sys.exit(0) + else: + print("SOME TESTS FAILED") + sys.exit(1)