whois_cli/
hyperlink.rs

1use regex::Regex;
2use std::env;
3use urlencoding::encode;
4
5/// Represents Regional Internet Registry URLs
6pub struct RirUrls;
7
8impl RirUrls {
9    /// Get the appropriate URL for a given RIR and search term
10    pub fn get_url(rir: &str, search_term: &str) -> String {
11        let encoded_term = encode(search_term);
12        
13        match rir.to_uppercase().as_str() {
14            "RIPE" => format!("https://apps.db.ripe.net/db-web-ui/query?searchtext={}", encoded_term),
15            "ARIN" => format!("https://search.arin.net/rdap/?query={}", encoded_term),
16            "APNIC" => format!("https://wq.apnic.net/apnic-bin/whois.pl?searchtext={}", encoded_term),
17            "LACNIC" => {
18                // LACNIC uses a different parameter format
19                format!("https://query.milacnic.lacnic.net/home?searchtext={}", encoded_term)
20            },
21            "AFRINIC" => format!("https://afrinic.net/whois?searchtext={}", encoded_term),
22            _ => {
23                // Fallback to RIPE for unknown RIRs
24                format!("https://apps.db.ripe.net/db-web-ui/query?searchtext={}", encoded_term)
25            }
26        }
27    }
28}
29
30/// Detect RIR from source field - more accurate than content-based detection
31pub fn detect_rir_from_source(response: &str) -> Vec<&'static str> {
32    let mut rirs = Vec::new();
33    
34    // Use regex to find all source fields
35    let source_regex = Regex::new(r"(?m)^source:\s*([A-Z-]+)").unwrap();
36    
37    for caps in source_regex.captures_iter(response) {
38        if let Some(source) = caps.get(1) {
39            let source_value = source.as_str().trim();
40            let rir = match source_value {
41                "RIPE" => Some("ripe"),
42                "ARIN" => Some("arin"),
43                "APNIC" => Some("apnic"),
44                "LACNIC" => Some("lacnic"),
45                "AFRINIC" => Some("afrinic"),
46                _ => None,
47            };
48            
49            if let Some(rir) = rir {
50                if !rirs.contains(&rir) {
51                    rirs.push(rir);
52                }
53            }
54        }
55    }
56    
57    rirs
58}
59
60/// Legacy function - detect which RIR the response is from (fallback method)
61pub fn detect_rir(response: &str) -> Option<&'static str> {
62    // First try source-based detection
63    let rirs = detect_rir_from_source(response);
64    if !rirs.is_empty() {
65        return Some(rirs[0]);
66    }
67    
68    // Fallback to content-based detection
69    if response.contains("% This is the RIPE Database query service") ||
70       response.contains("whois.ripe.net") ||
71       response.contains("RIPE-NCC") {
72        return Some("ripe");
73    }
74
75    if response.contains("American Registry for Internet Numbers") ||
76       response.contains("ARIN WHOIS data") ||
77       response.contains("NetRange:") ||
78       response.contains("whois.arin.net") {
79        return Some("arin");
80    }
81
82    if response.contains("Asia Pacific Network Information Centre") ||
83       response.contains("APNIC WHOIS Database") ||
84       response.contains("whois.apnic.net") {
85        return Some("apnic");
86    }
87
88    if response.contains("Latin American and Caribbean IP address Regional Registry") ||
89       response.contains("LACNIC WHOIS") ||
90       response.contains("whois.lacnic.net") {
91        return Some("lacnic");
92    }
93
94    if response.contains("African Network Information Centre") ||
95       response.contains("AFRINIC WHOIS") ||
96       response.contains("whois.afrinic.net") {
97        return Some("afrinic");
98    }
99
100    None
101}
102
103/// Check if the WHOIS response is from any RIR
104pub fn is_rir_response(response: &str) -> bool {
105    !detect_rir_from_source(response).is_empty() || detect_rir(response).is_some()
106}
107
108/// Check if the WHOIS response is from RIPE NCC
109pub fn is_ripe_response(response: &str) -> bool {
110    detect_rir_from_source(response).contains(&"ripe") || detect_rir(response) == Some("ripe")
111}
112
113/// Check if terminal supports hyperlinks (OSC 8) - improved Windows detection
114pub fn terminal_supports_hyperlinks() -> bool {
115    // Check for Windows Terminal first (most reliable)
116    if env::var("WT_SESSION").is_ok() || env::var("WT_PROFILE_ID").is_ok() {
117        return true;
118    }
119
120    // Check for PowerShell with Windows Terminal
121    if env::var("TERM_PROGRAM").map_or(false, |term| term == "vscode") {
122        return true;
123    }
124
125    // Check common environment variables that indicate hyperlink support
126    if let Ok(term) = env::var("TERM") {
127        // These terminals are known to support OSC 8
128        if term.contains("xterm") || 
129           term.contains("screen") || 
130           term.contains("tmux") ||
131           term == "alacritty" ||
132           term == "kitty" ||
133           term == "foot" ||
134           term.contains("256color") {
135            return true;
136        }
137    }
138
139    // Check for VTE-based terminals (GNOME Terminal, etc.)
140    if env::var("VTE_VERSION").is_ok() {
141        return true;
142    }
143
144    // Check for iTerm2
145    if env::var("ITERM_SESSION_ID").is_ok() || env::var("TERM_PROGRAM").map_or(false, |term| term == "iTerm.app") {
146        return true;
147    }
148
149    // Check for WezTerm
150    if env::var("WEZTERM_EXECUTABLE").is_ok() || env::var("TERM_PROGRAM").map_or(false, |term| term == "WezTerm") {
151        return true;
152    }
153
154    // Check for Hyper
155    if env::var("TERM_PROGRAM").map_or(false, |term| term == "Hyper") {
156        return true;
157    }
158
159    // Additional Windows Terminal detection
160    if cfg!(windows) {
161        // Check if we're in Windows Terminal by looking for common WT env vars
162        if env::var("SESSIONNAME").is_ok() || 
163           env::var("COMPUTERNAME").is_ok() {
164            // Try to detect modern Windows environments
165            if let Ok(term_program) = env::var("TERM_PROGRAM") {
166                if term_program.contains("WindowsTerminal") || term_program.contains("wt") {
167                    return true;
168                }
169            }
170        }
171    }
172
173    // Default to true for modern systems - most terminals support OSC 8 now
174    true
175}
176
177/// Create OSC 8 hyperlink
178pub fn create_hyperlink(url: &str, text: &str) -> String {
179    if !terminal_supports_hyperlinks() {
180        return text.to_string();
181    }
182
183    format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, text)
184}
185
186/// Split response into blocks by RIR source
187fn split_response_by_source(response: &str) -> Vec<(String, &'static str)> {
188    let mut blocks = Vec::new();
189    let lines: Vec<&str> = response.lines().collect();
190    let mut current_block = String::new();
191    let mut current_rir = None;
192    
193    for line in lines {
194        // Check if this line contains a source field
195        if let Some(caps) = Regex::new(r"^source:\s*([A-Z-]+)").unwrap().captures(line) {
196            if let Some(source) = caps.get(1) {
197                let source_value = source.as_str().trim();
198                let rir = match source_value {
199                    "RIPE" => Some("ripe"),
200                    "ARIN" => Some("arin"), 
201                    "APNIC" => Some("apnic"),
202                    "LACNIC" => Some("lacnic"),
203                    "AFRINIC" => Some("afrinic"),
204                    _ => None,
205                };
206                
207                // If we found a new RIR source and have a current block, save it
208                if let Some(current) = current_rir {
209                    if rir != Some(current) && !current_block.trim().is_empty() {
210                        blocks.push((current_block.clone(), current));
211                        current_block.clear();
212                    }
213                }
214                
215                current_rir = rir;
216            }
217        }
218        
219        current_block.push_str(line);
220        current_block.push('\n');
221    }
222    
223    // Add the last block
224    if let Some(rir) = current_rir {
225        if !current_block.trim().is_empty() {
226            blocks.push((current_block, rir));
227        }
228    } else if !current_block.trim().is_empty() {
229        // Fallback: try to detect RIR from content
230        if let Some(rir) = detect_rir(&current_block) {
231            blocks.push((current_block, rir));
232        }
233    }
234    
235    // If no blocks were created, treat entire response as one block
236    if blocks.is_empty() {
237        if let Some(rir) = detect_rir(response) {
238            blocks.push((response.to_string(), rir));
239        }
240    }
241    
242    blocks
243}
244
245/// Hyperlink processor for RIR database responses
246pub struct RirHyperlinkProcessor;
247
248impl RirHyperlinkProcessor {
249    pub fn new() -> Self {
250        Self
251    }
252
253    /// Process RIR response and add hyperlinks - handles multi-RIR responses
254    pub fn process(&self, response: &str) -> String {
255        if !terminal_supports_hyperlinks() {
256            return response.to_string();
257        }
258        
259        // Split response into blocks by RIR source
260        let blocks = split_response_by_source(response);
261        
262        if blocks.is_empty() {
263            return response.to_string();
264        }
265        
266        let mut processed_blocks = Vec::new();
267        
268        for (block, rir) in blocks {
269            let mut processed_block = block;
270            
271            // Apply RIR-specific patterns
272            match rir {
273                "ripe" => self.process_ripe(&mut processed_block),
274                "arin" => self.process_arin(&mut processed_block),
275                "apnic" => self.process_apnic(&mut processed_block),
276                "lacnic" => self.process_lacnic(&mut processed_block),
277                "afrinic" => self.process_afrinic(&mut processed_block),
278                _ => {}
279            }
280            
281            processed_blocks.push(processed_block);
282        }
283        
284        processed_blocks.join("")
285    }
286
287    fn apply_patterns(&self, processed: &mut String, patterns: Vec<(&str, &str)>, rir: &str) {
288        for (pattern_str, _) in patterns {
289            if let Ok(pattern) = Regex::new(pattern_str) {
290                *processed = pattern.replace_all(processed, |caps: &regex::Captures| {
291                    let _full_match = caps.get(0).unwrap().as_str();
292                    let prefix = caps.get(1).unwrap().as_str();
293                    let value = caps.get(2).unwrap().as_str();
294                    
295                    // Generate URL for the detected RIR
296                    let url = RirUrls::get_url(rir, value);
297                    let hyperlinked_value = create_hyperlink(&url, value);
298                    
299                    format!("{}{}", prefix, hyperlinked_value)
300                }).to_string();
301            }
302        }
303    }
304
305    fn process_ripe(&self, processed: &mut String) {
306        let patterns = vec![
307            // ASN patterns
308            (r"(?m)^(aut-num:\s+)(AS\d+)", ""),
309            (r"(?m)^(origin:\s+)(AS\d+)", ""),
310            
311            // IP network patterns
312            (r"(?m)^(inetnum:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s*-\s*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", ""),
313            (r"(?m)^(inet6num:\s+)([0-9a-fA-F:]+/\d+)", ""),
314            (r"(?m)^(route:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/\d+)", ""),
315            (r"(?m)^(route6:\s+)([0-9a-fA-F:]+/\d+)", ""),
316            
317            // Organization patterns
318            (r"(?m)^(organisation:\s+)(ORG-[A-Z0-9-]+)", ""),
319            (r"(?m)^(org:\s+)(ORG-[A-Z0-9-]+)", ""),
320            
321            // Person/Role patterns
322            (r"(?m)^(nic-hdl:\s+)([A-Z0-9-]+)", ""),
323            (r"(?m)^(admin-c:\s+)([A-Z0-9-]+)", ""),
324            (r"(?m)^(tech-c:\s+)([A-Z0-9-]+)", ""),
325            
326            // Maintainer patterns
327            (r"(?m)^(mntner:\s+)([A-Z][A-Z0-9-]*)", ""),
328            (r"(?m)^(mnt-by:\s+)([A-Z][A-Z0-9-]*)", ""),
329            
330            // Domain patterns
331            (r"(?m)^(domain:\s+)([a-zA-Z0-9.-]+\.arpa)", ""),
332            
333            // AS-block patterns
334            (r"(?m)^(as-block:\s+)(AS\d+\s*-\s*AS\d+)", ""),
335        ];
336
337        self.apply_patterns(processed, patterns, "RIPE");
338    }
339
340    fn process_arin(&self, processed: &mut String) {
341        let patterns = vec![
342            // ARIN-specific patterns
343            (r"(?m)^(NetRange:\s+)([0-9.-]+)", ""),
344            (r"(?m)^(CIDR:\s+)([0-9./]+)", ""),
345            (r"(?m)^(OriginAS:\s+)(AS\d+)", ""),
346            (r"(?m)^(OrgId:\s+)([A-Z0-9-]+)", ""),
347            (r"(?m)^(NetName:\s+)([A-Z0-9-]+)", ""),
348            
349            // Common ASN and IP patterns
350            (r"(?m)^(aut-num:\s+)(AS\d+)", ""),
351            (r"(?m)^(origin:\s+)(AS\d+)", ""),
352            (r"(?m)^(inetnum:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s*-\s*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", ""),
353            (r"(?m)^(inet6num:\s+)([0-9a-fA-F:]+/\d+)", ""),
354        ];
355
356        self.apply_patterns(processed, patterns, "ARIN");
357    }
358
359    fn process_apnic(&self, processed: &mut String) {
360        let patterns = vec![
361            // Common patterns for APNIC
362            (r"(?m)^(aut-num:\s+)(AS\d+)", ""),
363            (r"(?m)^(origin:\s+)(AS\d+)", ""),
364            (r"(?m)^(inetnum:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s*-\s*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", ""),
365            (r"(?m)^(inet6num:\s+)([0-9a-fA-F:]+/\d+)", ""),
366            (r"(?m)^(nic-hdl:\s+)([A-Z0-9-]+)", ""),
367            (r"(?m)^(admin-c:\s+)([A-Z0-9-]+)", ""),
368            (r"(?m)^(tech-c:\s+)([A-Z0-9-]+)", ""),
369        ];
370
371        self.apply_patterns(processed, patterns, "APNIC");
372    }
373
374    fn process_lacnic(&self, processed: &mut String) {
375        let patterns = vec![
376            // Common patterns for LACNIC
377            (r"(?m)^(aut-num:\s+)(AS\d+)", ""),
378            (r"(?m)^(origin:\s+)(AS\d+)", ""),
379            (r"(?m)^(inetnum:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s*-\s*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", ""),
380            (r"(?m)^(inet6num:\s+)([0-9a-fA-F:]+/\d+)", ""),
381            (r"(?m)^(nic-hdl:\s+)([A-Z0-9-]+)", ""),
382        ];
383
384        self.apply_patterns(processed, patterns, "LACNIC");
385    }
386
387    fn process_afrinic(&self, processed: &mut String) {
388        let patterns = vec![
389            // Common patterns for AFRINIC
390            (r"(?m)^(aut-num:\s+)(AS\d+)", ""),
391            (r"(?m)^(origin:\s+)(AS\d+)", ""),
392            (r"(?m)^(inetnum:\s+)([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s*-\s*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", ""),
393            (r"(?m)^(inet6num:\s+)([0-9a-fA-F:]+/\d+)", ""),
394            (r"(?m)^(nic-hdl:\s+)([A-Z0-9-]+)", ""),
395        ];
396
397        self.apply_patterns(processed, patterns, "AFRINIC");
398    }
399}
400
401impl Default for RirHyperlinkProcessor {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407// For backward compatibility
408pub type RipeHyperlinkProcessor = RirHyperlinkProcessor;
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_detect_rir_from_source() {
416        let multi_rir_response = r#"
417as-block: AS137530 - AS138553
418descr: APNIC ASN block
419source: APNIC
420
421aut-num: AS3333
422as-name: RIPE-NCC-AS
423source: RIPE
424        "#;
425        
426        let rirs = detect_rir_from_source(multi_rir_response);
427        assert!(rirs.contains(&"apnic"));
428        assert!(rirs.contains(&"ripe"));
429    }
430
431    #[test]
432    fn test_split_response_by_source() {
433        let multi_rir_response = r#"
434as-block: AS137530 - AS138553
435source: APNIC
436
437aut-num: AS3333  
438source: RIPE
439        "#;
440        
441        let blocks = split_response_by_source(multi_rir_response);
442        assert_eq!(blocks.len(), 2);
443    }
444
445    #[test]
446    fn test_create_hyperlink() {
447        let url = "https://example.com";
448        let text = "Example";
449        
450        let result = create_hyperlink(url, text);
451        assert!(result.contains("Example"));
452    }
453
454    #[test]
455    fn test_rir_urls() {
456        let query_url = RirUrls::get_url("RIPE", "AS3333");
457        assert!(query_url.contains("AS3333"));
458        assert!(!query_url.contains("types=")); // No types parameter
459        assert!(query_url.contains("apps.db.ripe.net"));
460        
461        // Test different RIRs
462        let arin_url = RirUrls::get_url("ARIN", "AS3333");
463        assert!(arin_url.contains("search.arin.net"));
464        assert!(arin_url.contains("AS3333"));
465        
466        let apnic_url = RirUrls::get_url("APNIC", "AS3333");
467        assert!(apnic_url.contains("wq.apnic.net"));
468        assert!(apnic_url.contains("AS3333"));
469        
470        let lacnic_url = RirUrls::get_url("LACNIC", "AS3333");
471        assert!(lacnic_url.contains("query.milacnic.lacnic.net"));
472        assert!(lacnic_url.contains("AS3333"));
473        
474        let afrinic_url = RirUrls::get_url("AFRINIC", "AS3333");
475        assert!(afrinic_url.contains("afrinic.net"));
476        assert!(afrinic_url.contains("AS3333"));
477    }
478}