Skip to content

Commit f7eeef4

Browse files
WomB0ComB0clauderesq-swgemini-code-assist[bot]
authored
feat: repo hardening — RAII terminal guard, cargo-deny, 67 new tests (#40)
* feat: repo hardening — RAII terminal guard, cargo-deny, 67 new tests RAII Terminal Guard: - terminal::init() now returns TerminalGuard with Drop impl - Automatic cleanup on panic/early-return — no more wedged terminals - All 6 TUI apps migrated to guard pattern cargo-deny: - deny.toml: advisory denial, permissive license allowlist, copyleft denial, duplicate crate warnings, source restrictions - .github/workflows/deny.yml: CI enforcement on push + PRs Tests (67 new, 281 total): - resq-dsa: 46 new tests across all 5 modules (bloom, count_min, graph, heap, trie) covering edge cases, invariants, and error paths - resq-tui: 21 new tests for format_bytes, format_duration, centered_rect, Theme::default, and SPINNER_FRAMES Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update crates/resq-tui/src/terminal.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: ResQ <engineer@resq.software> * fix: address all PR review feedback — clippy, deny.toml, terminal safety CI fixes: - Fix clippy approx_constant (3.14 → 3.25), pedantic casts (as → From), format strings, and always-true comparisons across test code - Fix deny.toml: use advisories v2 format, deny wildcards, scope allow-org to resq-software only Terminal safety: - Add #[must_use] on TerminalGuard - Clean up partial init on failure (restore if Terminal::new errors) - run_loop accepts &mut Term for flexibility (Deref still works) - Health-checker: unconditional DisableMouseCapture cleanup on all paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use restore() for consistent cleanup on EnterAlternateScreen failure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Signed-off-by: ResQ <engineer@resq.software> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: ResQ <engineer@resq.software> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 96809e6 commit f7eeef4

15 files changed

Lines changed: 829 additions & 30 deletions

File tree

.github/workflows/deny.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2026 ResQ
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: cargo-deny
16+
17+
on:
18+
push:
19+
branches: [master]
20+
pull_request:
21+
22+
jobs:
23+
cargo-deny:
24+
name: cargo-deny
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 10
27+
steps:
28+
- uses: actions/checkout@v4
29+
- uses: EmbarkStudios/cargo-deny-action@v2

crates/resq-clean/src/main.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,8 @@ async fn main() -> Result<()> {
190190
let mut app = App::new(root, args.dry_run);
191191
app.scan();
192192

193-
let mut terminal = terminal::init()?;
194-
let result = terminal::run_loop(&mut terminal, 100, &mut app);
195-
terminal::restore();
196-
result
193+
let mut guard = terminal::init()?;
194+
terminal::run_loop(&mut guard, 100, &mut app)
197195
}
198196

199197
fn draw_ui(f: &mut Frame, app: &mut App) {

crates/resq-deploy/src/main.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,8 @@ async fn main() -> anyhow::Result<()> {
263263
let mut app = App::new(args.env, args.k8s, project_root);
264264
app.refresh_status();
265265

266-
let mut term = terminal::init()?;
267-
let result = terminal::run_loop(&mut term, 50, &mut app);
268-
terminal::restore();
269-
result
266+
let mut guard = terminal::init()?;
267+
terminal::run_loop(&mut guard, 50, &mut app)
270268
}
271269

272270
fn draw_ui(f: &mut Frame, app: &mut App) {

crates/resq-dsa/src/bloom.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,93 @@ mod tests {
286286
bf.add("");
287287
assert!(bf.has(""));
288288
}
289+
290+
#[test]
291+
fn false_positive_rate_within_bounds() {
292+
let capacity = 10_000;
293+
let target_fpr = 0.05;
294+
let mut bf = BloomFilter::new(capacity, target_fpr);
295+
296+
// Insert `capacity` items
297+
for i in 0..capacity {
298+
bf.add(format!("present-{i}"));
299+
}
300+
301+
// Check a disjoint set of items that were NOT inserted
302+
let test_count = 50_000;
303+
let mut false_positives = 0;
304+
for i in 0..test_count {
305+
if bf.has(format!("absent-{i}")) {
306+
false_positives += 1;
307+
}
308+
}
309+
310+
let observed_fpr = f64::from(false_positives) / f64::from(test_count);
311+
// Allow 2x the target rate as headroom for hash quality variance
312+
assert!(
313+
observed_fpr < target_fpr * 2.0,
314+
"Observed FPR {observed_fpr:.4} exceeds 2x target {target_fpr}"
315+
);
316+
}
317+
318+
#[test]
319+
fn empty_filter_has_returns_false() {
320+
let bf = BloomFilter::new(100, 0.01);
321+
assert!(!bf.has("anything"));
322+
assert!(!bf.has(""));
323+
assert!(!bf.has(b"bytes" as &[u8]));
324+
}
325+
326+
#[test]
327+
fn single_item_insert_and_query() {
328+
let mut bf = BloomFilter::new(1000, 0.01);
329+
bf.add("only-one");
330+
assert!(bf.has("only-one"));
331+
assert_eq!(bf.len(), 1);
332+
assert!(!bf.is_empty());
333+
}
334+
335+
#[test]
336+
fn clear_then_has_returns_false() {
337+
let mut bf = BloomFilter::new(100, 0.01);
338+
bf.add("x");
339+
bf.add("y");
340+
assert!(bf.has("x"));
341+
bf.clear();
342+
assert!(!bf.has("x"));
343+
assert!(!bf.has("y"));
344+
assert_eq!(bf.len(), 0);
345+
assert!(bf.is_empty());
346+
}
347+
348+
#[test]
349+
fn duplicate_add_does_not_corrupt() {
350+
let mut bf = BloomFilter::new(100, 0.01);
351+
bf.add("dup");
352+
bf.add("dup");
353+
bf.add("dup");
354+
assert!(bf.has("dup"));
355+
// Count tracks calls, not unique items
356+
assert_eq!(bf.len(), 3);
357+
}
358+
359+
#[test]
360+
fn from_raw_params_minimal() {
361+
// Smallest possible filter: 1 bit, 1 hash
362+
let mut bf = BloomFilter::from_raw_params(1, 1);
363+
bf.add("a");
364+
assert!(bf.has("a"));
365+
}
366+
367+
#[test]
368+
#[should_panic(expected = "bit_count must be > 0")]
369+
fn from_raw_params_zero_bits_panics() {
370+
let _ = BloomFilter::from_raw_params(0, 1);
371+
}
372+
373+
#[test]
374+
#[should_panic(expected = "hash_count must be > 0")]
375+
fn from_raw_params_zero_hashes_panics() {
376+
let _ = BloomFilter::from_raw_params(1, 0);
377+
}
289378
}

crates/resq-dsa/src/count_min.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,101 @@ mod tests {
219219
cms.increment(b"sensor" as &[u8], 3);
220220
assert!(cms.estimate(b"sensor" as &[u8]) >= 3);
221221
}
222+
223+
#[test]
224+
fn never_undercounts() {
225+
let mut cms = CountMinSketch::new(0.01, 0.01);
226+
// Insert many different keys and verify each estimate >= actual count
227+
for i in 0..200 {
228+
let key = format!("key-{i}");
229+
let count = u64::try_from(i).unwrap() + 1;
230+
cms.increment(&key, count);
231+
assert!(
232+
cms.estimate(&key) >= count,
233+
"Undercount detected for {key}: estimate {} < actual {count}",
234+
cms.estimate(&key)
235+
);
236+
}
237+
}
238+
239+
#[test]
240+
fn zero_increment_does_not_change_estimate() {
241+
let mut cms = CountMinSketch::new(0.01, 0.01);
242+
cms.increment("k", 0);
243+
assert_eq!(cms.estimate("k"), 0);
244+
}
245+
246+
#[test]
247+
fn zero_increment_after_nonzero() {
248+
let mut cms = CountMinSketch::new(0.01, 0.01);
249+
cms.increment("k", 5);
250+
cms.increment("k", 0);
251+
assert!(cms.estimate("k") >= 5);
252+
}
253+
254+
#[test]
255+
fn very_large_counts() {
256+
let mut cms = CountMinSketch::new(0.01, 0.01);
257+
let large = u64::MAX / 2;
258+
cms.increment("big", large);
259+
assert!(cms.estimate("big") >= large);
260+
}
261+
262+
#[test]
263+
fn saturating_add_on_overflow() {
264+
let mut cms = CountMinSketch::new(0.01, 0.01);
265+
cms.increment("max", u64::MAX);
266+
cms.increment("max", 1);
267+
// Should saturate, not wrap around
268+
assert_eq!(cms.estimate("max"), u64::MAX);
269+
}
270+
271+
#[test]
272+
fn unseen_key_returns_zero() {
273+
let cms = CountMinSketch::new(0.01, 0.01);
274+
assert_eq!(cms.estimate("never-added"), 0);
275+
}
276+
277+
#[test]
278+
fn from_raw_params_works() {
279+
let mut cms = CountMinSketch::from_raw_params(100, 5);
280+
cms.increment("x", 7);
281+
assert!(cms.estimate("x") >= 7);
282+
assert_eq!(cms.estimate("y"), 0);
283+
}
284+
285+
#[test]
286+
#[should_panic(expected = "width must be > 0")]
287+
fn from_raw_params_zero_width_panics() {
288+
let _ = CountMinSketch::from_raw_params(0, 5);
289+
}
290+
291+
#[test]
292+
#[should_panic(expected = "depth must be > 0")]
293+
fn from_raw_params_zero_depth_panics() {
294+
let _ = CountMinSketch::from_raw_params(5, 0);
295+
}
296+
297+
#[test]
298+
#[should_panic(expected = "epsilon must be in (0,1)")]
299+
fn panics_on_zero_epsilon() {
300+
let _ = CountMinSketch::new(0.0, 0.01);
301+
}
302+
303+
#[test]
304+
#[should_panic(expected = "delta must be in (0,1)")]
305+
fn panics_on_one_delta() {
306+
let _ = CountMinSketch::new(0.01, 1.0);
307+
}
308+
309+
#[test]
310+
fn multiple_keys_independent() {
311+
let mut cms = CountMinSketch::new(0.001, 0.001);
312+
cms.increment("alpha", 100);
313+
cms.increment("beta", 200);
314+
cms.increment("gamma", 300);
315+
assert!(cms.estimate("alpha") >= 100);
316+
assert!(cms.estimate("beta") >= 200);
317+
assert!(cms.estimate("gamma") >= 300);
318+
}
222319
}

crates/resq-dsa/src/graph.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,4 +357,123 @@ mod tests {
357357
let (_, cost) = g.dijkstra(&"A", &"C").expect("Path should exist");
358358
assert_eq!(cost, 2); // takes the cheaper A->B edge
359359
}
360+
361+
#[test]
362+
fn disconnected_graph_dijkstra_returns_none() {
363+
let mut g = Graph::<&str>::new();
364+
g.add_edge("A", "B", 1);
365+
g.add_edge("C", "D", 1);
366+
// A and C are in different components
367+
assert!(g.dijkstra(&"A", &"D").is_none());
368+
assert!(g.dijkstra(&"C", &"B").is_none());
369+
}
370+
371+
#[test]
372+
fn self_loop_dijkstra() {
373+
let mut g = Graph::<&str>::new();
374+
g.add_edge("A", "A", 5);
375+
g.add_edge("A", "B", 1);
376+
let (path, cost) = g.dijkstra(&"A", &"B").expect("Path should exist");
377+
assert_eq!(path, vec!["A", "B"]);
378+
assert_eq!(cost, 1);
379+
}
380+
381+
#[test]
382+
fn single_node_graph_dijkstra_self() {
383+
let g = Graph::<&str>::new();
384+
// No edges at all; start == end => cost 0
385+
let (path, cost) = g.dijkstra(&"X", &"X").expect("Self path should exist");
386+
assert_eq!(path, vec!["X"]);
387+
assert_eq!(cost, 0);
388+
}
389+
390+
#[test]
391+
fn single_node_graph_dijkstra_unreachable() {
392+
let g = Graph::<&str>::new();
393+
assert!(g.dijkstra(&"X", &"Y").is_none());
394+
}
395+
396+
#[test]
397+
fn astar_nontrivial_heuristic() {
398+
// Grid-like graph where heuristic is Manhattan distance
399+
// Nodes are (row, col) encoded as row*10+col
400+
let mut g = Graph::<i32>::new();
401+
// Row 0: 0->1->2
402+
g.add_edge(0, 1, 1);
403+
g.add_edge(1, 2, 1);
404+
// Row 1: 10->11->12
405+
g.add_edge(10, 11, 1);
406+
g.add_edge(11, 12, 1);
407+
// Vertical: 0->10, 1->11, 2->12
408+
g.add_edge(0, 10, 1);
409+
g.add_edge(1, 11, 1);
410+
g.add_edge(2, 12, 1);
411+
// Also a long path: 0->10->11->12
412+
// Shortest from 0 to 12: 0->1->2->12 (cost 3) or 0->1->11->12 (cost 3)
413+
414+
let manhattan = |a: &i32, b: &i32| -> u64 {
415+
let (ar, ac) = (a / 10, a % 10);
416+
let (br, bc) = (b / 10, b % 10);
417+
u64::from((ar - br).unsigned_abs()) + u64::from((ac - bc).unsigned_abs())
418+
};
419+
420+
let (path, cost) = g.astar(&0, &12, manhattan).expect("Path should exist");
421+
assert_eq!(cost, 3);
422+
assert_eq!(*path.first().unwrap(), 0);
423+
assert_eq!(*path.last().unwrap(), 12);
424+
}
425+
426+
#[test]
427+
fn astar_unreachable() {
428+
let mut g = Graph::<u64>::new();
429+
g.add_edge(0, 1, 1);
430+
assert!(g.astar(&0, &99, |a, b| a.abs_diff(*b)).is_none());
431+
}
432+
433+
#[test]
434+
fn bfs_ordering_is_breadth_first() {
435+
// Build a tree:
436+
// A
437+
// / \
438+
// B C
439+
// / \
440+
// D E
441+
let mut g = Graph::<&str>::new();
442+
g.add_edge("A", "B", 1);
443+
g.add_edge("A", "C", 1);
444+
g.add_edge("B", "D", 1);
445+
g.add_edge("B", "E", 1);
446+
let result = g.bfs(&"A");
447+
// A must be first
448+
assert_eq!(result[0], "A");
449+
// B and C must come before D and E
450+
let pos = |node: &str| result.iter().position(|&n| n == node).unwrap();
451+
assert!(pos("B") < pos("D"));
452+
assert!(pos("B") < pos("E"));
453+
assert!(pos("C") < pos("D"));
454+
assert!(pos("C") < pos("E"));
455+
}
456+
457+
#[test]
458+
fn bfs_single_node_no_edges() {
459+
let g = Graph::<&str>::new();
460+
let result = g.bfs(&"lonely");
461+
assert_eq!(result, vec!["lonely"]);
462+
}
463+
464+
#[test]
465+
fn default_creates_empty_graph() {
466+
let g = Graph::<i32>::default();
467+
assert!(g.dijkstra(&0, &1).is_none());
468+
assert_eq!(g.bfs(&0), vec![0]);
469+
}
470+
471+
#[test]
472+
fn large_weight_path() {
473+
let mut g = Graph::<&str>::new();
474+
g.add_edge("A", "B", u64::MAX - 1);
475+
let (path, cost) = g.dijkstra(&"A", &"B").expect("Path should exist");
476+
assert_eq!(path, vec!["A", "B"]);
477+
assert_eq!(cost, u64::MAX - 1);
478+
}
360479
}

0 commit comments

Comments
 (0)