Skip to content

Commit 8d33b09

Browse files
committed
load OS truststore into the existing root store
1 parent 4d495e9 commit 8d33b09

4 files changed

Lines changed: 326 additions & 37 deletions

File tree

src/engine/traceroute.rs

Lines changed: 105 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -248,23 +248,18 @@ async fn run_system_traceroute(
248248
let dest_ip_str = destination_ip.to_string();
249249

250250
// Determine which command to use based on OS
251+
// Note: -n / -d intentionally NOT passed so the OS resolves hostnames.
251252
let (cmd, args): (&'static str, Vec<String>) = if cfg!(target_os = "windows") {
252253
(
253254
"tracert",
254-
vec![
255-
"-h".to_string(),
256-
max_hops.to_string(),
257-
"-d".to_string(),
258-
dest.clone(),
259-
],
255+
vec!["-h".to_string(), max_hops.to_string(), dest.clone()],
260256
)
261257
} else {
262258
(
263259
"traceroute",
264260
vec![
265261
"-m".to_string(),
266262
max_hops.to_string(),
267-
"-n".to_string(),
268263
"-q".to_string(),
269264
"3".to_string(),
270265
dest.clone(),
@@ -277,6 +272,15 @@ async fn run_system_traceroute(
277272
.context("Traceroute task failed")?
278273
.context("Failed to execute traceroute command")?;
279274

275+
if !output.status.success() {
276+
let stderr = String::from_utf8_lossy(&output.stderr);
277+
return Err(anyhow::anyhow!(
278+
"traceroute exited with {}: {}",
279+
output.status,
280+
stderr.trim()
281+
));
282+
}
283+
280284
let stdout = String::from_utf8_lossy(&output.stdout);
281285
let hops = parse_traceroute_output(&stdout, event_tx).await;
282286

@@ -331,16 +335,19 @@ async fn parse_traceroute_output(
331335
}
332336

333337
/// Parse a single hop line from traceroute output.
338+
///
339+
/// Handles three formats:
340+
/// - Linux/macOS with DNS: `1 host.name (1.2.3.4) 0.5 ms 0.4 ms 0.6 ms`
341+
/// - Linux/macOS without DNS: `1 1.2.3.4 0.5 ms 0.4 ms 0.6 ms`
342+
/// - Windows with DNS: `1 <1 ms <1 ms <1 ms host.name [1.2.3.4]`
334343
fn parse_hop_line(line: &str) -> Option<TracerouteHop> {
335344
let parts: Vec<&str> = line.split_whitespace().collect();
336345
if parts.is_empty() {
337346
return None;
338347
}
339348

340-
// First part should be hop number
341349
let hop_number: u8 = parts.first()?.parse().ok()?;
342350

343-
// Check for timeout line (all asterisks)
344351
if parts.iter().skip(1).all(|p| *p == "*") {
345352
return Some(TracerouteHop {
346353
hop_number,
@@ -351,37 +358,44 @@ fn parse_hop_line(line: &str) -> Option<TracerouteHop> {
351358
});
352359
}
353360

354-
// Find IP address and RTT values
355361
let mut ip_address: Option<String> = None;
362+
let mut hostname: Option<String> = None;
356363
let mut rtts: Vec<f64> = Vec::new();
364+
let mut prev_candidate: Option<String> = None;
357365

358366
for part in parts.iter().skip(1) {
359-
// Skip "ms" markers
360367
if *part == "ms" {
361368
continue;
362369
}
363370

364-
// Try to parse as RTT (number)
365-
if let Ok(rtt) = part.trim_end_matches("ms").parse::<f64>() {
371+
// Numeric RTT (handles plain `0.5`, `0.5ms`, and Windows `<1`).
372+
let cleaned = part.trim_start_matches('<').trim_end_matches("ms");
373+
if let Ok(rtt) = cleaned.parse::<f64>() {
366374
rtts.push(rtt);
375+
prev_candidate = None;
367376
continue;
368377
}
369378

370-
// Handle Windows "<1 ms" format
371-
if part.starts_with('<') {
372-
if let Ok(rtt) = part
373-
.trim_start_matches('<')
374-
.trim_end_matches("ms")
375-
.parse::<f64>()
376-
{
377-
rtts.push(rtt);
378-
continue;
379+
let was_wrapped = part.starts_with('(') || part.starts_with('[');
380+
let stripped = part
381+
.trim_start_matches(['(', '['])
382+
.trim_end_matches([')', ']']);
383+
384+
if stripped.parse::<IpAddr>().is_ok() {
385+
if ip_address.is_none() {
386+
ip_address = Some(stripped.to_string());
387+
if was_wrapped {
388+
if let Some(prev) = prev_candidate.take() {
389+
if prev != stripped {
390+
hostname = Some(prev);
391+
}
392+
}
393+
}
379394
}
380-
}
381-
382-
// Try to parse as IP address
383-
if part.parse::<IpAddr>().is_ok() || (part.contains('.') && !part.contains("ms")) {
384-
ip_address = Some(part.to_string());
395+
prev_candidate = None;
396+
} else {
397+
// Not an IP, not a number: candidate hostname for the next wrapped IP.
398+
prev_candidate = Some(part.to_string());
385399
}
386400
}
387401

@@ -392,8 +406,71 @@ fn parse_hop_line(line: &str) -> Option<TracerouteHop> {
392406
Some(TracerouteHop {
393407
hop_number,
394408
ip_address,
395-
hostname: None,
409+
hostname,
396410
rtt_ms: rtts,
397411
timeout: false,
398412
})
399413
}
414+
415+
#[cfg(test)]
416+
mod tests {
417+
use super::*;
418+
419+
#[test]
420+
fn parses_linux_with_hostname() {
421+
let line = " 1 host.example.com (1.2.3.4) 0.5 ms 0.4 ms 0.6 ms";
422+
let hop = parse_hop_line(line).unwrap();
423+
assert_eq!(hop.hop_number, 1);
424+
assert_eq!(hop.ip_address.as_deref(), Some("1.2.3.4"));
425+
assert_eq!(hop.hostname.as_deref(), Some("host.example.com"));
426+
assert_eq!(hop.rtt_ms, vec![0.5, 0.4, 0.6]);
427+
assert!(!hop.timeout);
428+
}
429+
430+
#[test]
431+
fn parses_linux_without_dns() {
432+
let line = " 2 1.2.3.4 0.5 ms 0.4 ms 0.6 ms";
433+
let hop = parse_hop_line(line).unwrap();
434+
assert_eq!(hop.ip_address.as_deref(), Some("1.2.3.4"));
435+
assert_eq!(hop.hostname, None);
436+
assert_eq!(hop.rtt_ms, vec![0.5, 0.4, 0.6]);
437+
}
438+
439+
#[test]
440+
fn parses_linux_when_hostname_equals_ip() {
441+
// When DNS fails, traceroute often shows `ip (ip)` with both being identical.
442+
let line = " 3 10.0.0.1 (10.0.0.1) 5.2 ms 4.8 ms 5.1 ms";
443+
let hop = parse_hop_line(line).unwrap();
444+
assert_eq!(hop.ip_address.as_deref(), Some("10.0.0.1"));
445+
assert_eq!(hop.hostname, None, "hostname should be elided when same as ip");
446+
}
447+
448+
#[test]
449+
fn parses_timeout_line() {
450+
let line = " 5 * * *";
451+
let hop = parse_hop_line(line).unwrap();
452+
assert_eq!(hop.ip_address, None);
453+
assert_eq!(hop.hostname, None);
454+
assert!(hop.timeout);
455+
assert!(hop.rtt_ms.is_empty());
456+
}
457+
458+
#[test]
459+
fn parses_windows_with_hostname() {
460+
let line = " 1 <1 ms <1 ms <1 ms router.local [192.168.1.1]";
461+
let hop = parse_hop_line(line).unwrap();
462+
assert_eq!(hop.ip_address.as_deref(), Some("192.168.1.1"));
463+
assert_eq!(hop.hostname.as_deref(), Some("router.local"));
464+
assert_eq!(hop.rtt_ms, vec![1.0, 1.0, 1.0]);
465+
}
466+
467+
#[test]
468+
fn first_ip_wins_on_multi_router_hop() {
469+
// Some hops have two routers responding; we keep the first IP/hostname pair.
470+
let line = " 5 a.example.com (1.1.1.1) 260.2 ms b.example.com (2.2.2.2) 260.1 ms 260.0 ms";
471+
let hop = parse_hop_line(line).unwrap();
472+
assert_eq!(hop.ip_address.as_deref(), Some("1.1.1.1"));
473+
assert_eq!(hop.hostname.as_deref(), Some("a.example.com"));
474+
assert_eq!(hop.rtt_ms.len(), 3);
475+
}
476+
}

src/tui/mod.rs

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod export;
44
mod help;
55
mod history;
66
mod state;
7+
mod traceroute;
78

89
pub use state::UiState;
910

@@ -79,6 +80,8 @@ pub async fn run(args: Cli) -> Result<()> {
7980
.and_then(|n| n.to_str())
8081
.map(|s| s.to_string());
8182
state.proxy_url = args.proxy.clone();
83+
state.traceroute_enabled = args.traceroute;
84+
state.traceroute_max_hops = args.traceroute_max_hops;
8285

8386
// Spawn background task to check for updates (non-blocking, silent on error)
8487
let (update_tx, mut update_rx) = tokio::sync::mpsc::channel::<Option<String>>(1);
@@ -269,6 +272,7 @@ pub async fn run(args: Cli) -> Result<()> {
269272
state.tls_summary = None;
270273
state.ip_comparison = None;
271274
state.traceroute_summary = None;
275+
state.traceroute_hops.clear();
272276
run_ctx = Some(start_run(&args).await?);
273277
}
274278
}
@@ -350,15 +354,17 @@ pub async fn run(args: Cli) -> Result<()> {
350354
}
351355
(KeyModifiers::SHIFT, KeyCode::BackTab) => {
352356
// Shift+Tab cycles backwards
353-
let new_tab = if state.tab == 0 { 3 } else { state.tab - 1 };
357+
let tab_count = if state.traceroute_enabled { 5 } else { 4 };
358+
let new_tab = if state.tab == 0 { tab_count - 1 } else { state.tab - 1 };
354359
state.tab = new_tab;
355360
if new_tab == 1 {
356361
state.history_selected = 0;
357362
state.history_scroll_offset = 0;
358363
}
359364
}
360365
(_, KeyCode::Tab) => {
361-
let new_tab = (state.tab + 1) % 4;
366+
let tab_count = if state.traceroute_enabled { 5 } else { 4 };
367+
let new_tab = (state.tab + 1) % tab_count;
362368
state.tab = new_tab;
363369
// Reset history selection when switching to history tab
364370
if new_tab == 1 {
@@ -367,7 +373,7 @@ pub async fn run(args: Cli) -> Result<()> {
367373
}
368374
}
369375
(_, KeyCode::Char('?')) => {
370-
state.tab = 3; // help
376+
state.tab = if state.traceroute_enabled { 4 } else { 3 }; // help
371377
}
372378
// History navigation and deletion (only when on History tab)
373379
(_, KeyCode::Up) | (_, KeyCode::Char('k')) => {
@@ -885,6 +891,7 @@ fn apply_event(state: &mut UiState, ev: TestEvent) {
885891
.map(|r| format!("{:.1}ms", r))
886892
.unwrap_or_else(|| "*".to_string());
887893
state.info = format!("Traceroute hop {}: {} {}", hop_number, addr, rtt);
894+
state.traceroute_hops.push(hop);
888895
}
889896
TestEvent::TracerouteComplete { summary } => {
890897
state.info = format!(
@@ -907,12 +914,17 @@ fn draw(area: Rect, f: &mut ratatui::Frame, state: &mut UiState) {
907914
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
908915
.split(area);
909916

910-
let tabs = Tabs::new(vec![
917+
let mut tab_titles: Vec<Line> = vec![
911918
Line::from("Dashboard"),
912919
Line::from("History"),
913-
Line::from("Charts"),
914-
Line::from("Help"),
915-
])
920+
];
921+
if state.traceroute_enabled {
922+
tab_titles.push(Line::from("Traceroute"));
923+
}
924+
tab_titles.push(Line::from("Charts"));
925+
tab_titles.push(Line::from("Help"));
926+
927+
let tabs = Tabs::new(tab_titles)
916928
.select(state.tab)
917929
.block(
918930
Block::default()
@@ -929,6 +941,9 @@ fn draw(area: Rect, f: &mut ratatui::Frame, state: &mut UiState) {
929941
.highlight_style(Style::default().fg(Color::Yellow));
930942
f.render_widget(tabs, chunks[0]);
931943

944+
let traceroute_idx: Option<usize> = if state.traceroute_enabled { Some(2) } else { None };
945+
let charts_idx: usize = if state.traceroute_enabled { 3 } else { 2 };
946+
932947
match state.tab {
933948
0 => draw_dashboard(chunks[1], f, state),
934949
1 => {
@@ -938,7 +953,8 @@ fn draw(area: Rect, f: &mut ratatui::Frame, state: &mut UiState) {
938953
show_history(chunks[1], f, &mut *state)
939954
}
940955
}
941-
2 => draw_charts(chunks[1], f, state),
956+
i if Some(i) == traceroute_idx => traceroute::draw_traceroute(chunks[1], f, state),
957+
i if i == charts_idx => draw_charts(chunks[1], f, state),
942958
_ => draw_help(chunks[1], f),
943959
}
944960
}

src/tui/state.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::model::{DnsSummary, IpVersionComparison, Phase, RunResult, TlsSummary, TracerouteSummary};
1+
use crate::model::{DnsSummary, IpVersionComparison, Phase, RunResult, TlsSummary, TracerouteHop, TracerouteSummary};
22
use ratatui::{
33
style::Color,
44
style::Style,
@@ -89,6 +89,9 @@ pub struct UiState {
8989
pub tls_summary: Option<TlsSummary>,
9090
pub ip_comparison: Option<IpVersionComparison>,
9191
pub traceroute_summary: Option<TracerouteSummary>,
92+
pub traceroute_enabled: bool,
93+
pub traceroute_max_hops: u8,
94+
pub traceroute_hops: Vec<TracerouteHop>,
9295
/// None = check not completed, Some(None) = on latest, Some(Some(v)) = update available
9396
pub update_status: Option<Option<String>>,
9497
}
@@ -167,6 +170,9 @@ impl Default for UiState {
167170
tls_summary: None,
168171
ip_comparison: None,
169172
traceroute_summary: None,
173+
traceroute_enabled: false,
174+
traceroute_max_hops: 30,
175+
traceroute_hops: Vec::new(),
170176
update_status: None,
171177
}
172178
}

0 commit comments

Comments
 (0)