@@ -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]`
334343fn 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+ }
0 commit comments