Skip to main content

purple_ssh/
quick_add.rs

1/// Parsed target from `user@hostname:port` format.
2#[derive(Debug, Clone, PartialEq)]
3pub struct ParsedTarget {
4    pub user: String,
5    pub hostname: String,
6    pub port: u16,
7}
8
9/// Parse a target string in the format `[user@]hostname[:port]`.
10pub fn parse_target(target: &str) -> Result<ParsedTarget, String> {
11    if target.is_empty() {
12        return Err("Target can't be empty.".to_string());
13    }
14
15    let (user, rest) = if let Some(at_pos) = target.find('@') {
16        let user = &target[..at_pos];
17        if user.is_empty() {
18            return Err("User part before @ can't be empty.".to_string());
19        }
20        (user.to_string(), &target[at_pos + 1..])
21    } else {
22        (String::new(), target)
23    };
24
25    let (hostname, port) = if rest.starts_with('[') {
26        // Bracketed IPv6: [2001:db8::1]:port
27        if let Some(bracket_end) = rest.find(']') {
28            let host = &rest[1..bracket_end];
29            let after = &rest[bracket_end + 1..];
30            if let Some(port_str) = after.strip_prefix(':') {
31                if let Ok(port) = port_str.parse::<u16>() {
32                    if port == 0 {
33                        return Err("Port 0? Bold choice, but no. Try 1-65535.".to_string());
34                    }
35                    (host.to_string(), port)
36                } else {
37                    return Err("Invalid port after bracketed host.".to_string());
38                }
39            } else if after.is_empty() {
40                (host.to_string(), 22)
41            } else {
42                return Err("Unexpected text after closing bracket.".to_string());
43            }
44        } else {
45            return Err("Missing closing bracket for IPv6 address.".to_string());
46        }
47    } else if let Some(colon_pos) = rest.rfind(':') {
48        let port_str = &rest[colon_pos + 1..];
49        let host_part = &rest[..colon_pos];
50        // Only treat as host:port if the host part has no colons (not bare IPv6)
51        if !host_part.contains(':') {
52            if port_str.is_empty() {
53                return Err("Trailing colon with no port. Try host:22 or just host.".to_string());
54            }
55            if let Ok(port) = port_str.parse::<u16>() {
56                if port == 0 {
57                    return Err("Port 0? Bold choice, but no. Try 1-65535.".to_string());
58                }
59                (host_part.to_string(), port)
60            } else {
61                return Err(format!(
62                    "'{}' is not a valid port number. Ports are 1-65535.",
63                    port_str
64                ));
65            }
66        } else {
67            // Multiple colons = bare IPv6 address, no port extraction
68            (rest.to_string(), 22)
69        }
70    } else {
71        (rest.to_string(), 22)
72    };
73
74    if hostname.is_empty() {
75        return Err("Hostname can't be empty.".to_string());
76    }
77
78    if hostname.chars().any(|c| c.is_control() || c == ' ') {
79        return Err("Hostname contains invalid characters.".to_string());
80    }
81    if !user.is_empty() && user.chars().any(|c| c.is_control() || c == ' ') {
82        return Err("User contains invalid characters.".to_string());
83    }
84
85    Ok(ParsedTarget {
86        user,
87        hostname,
88        port,
89    })
90}
91
92/// Check if a string looks like a smart-paste target (contains @ or host:port).
93pub fn looks_like_target(s: &str) -> bool {
94    if s.contains('@') {
95        return true;
96    }
97    // Bracketed IPv6 with port: [::1]:22
98    if s.starts_with('[') {
99        return true;
100    }
101    if let Some(colon_pos) = s.rfind(':') {
102        let before = &s[..colon_pos];
103        let after = &s[colon_pos + 1..];
104        // Only match host:port if no colons in host part (avoids bare IPv6)
105        return !before.contains(':')
106            && !after.is_empty()
107            && after.chars().all(|c| c.is_ascii_digit());
108    }
109    false
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_full_target() {
118        let result = parse_target("admin@example.com:2222").unwrap();
119        assert_eq!(result.user, "admin");
120        assert_eq!(result.hostname, "example.com");
121        assert_eq!(result.port, 2222);
122    }
123
124    #[test]
125    fn test_user_and_host() {
126        let result = parse_target("root@10.0.0.1").unwrap();
127        assert_eq!(result.user, "root");
128        assert_eq!(result.hostname, "10.0.0.1");
129        assert_eq!(result.port, 22);
130    }
131
132    #[test]
133    fn test_host_and_port() {
134        let result = parse_target("example.com:8022").unwrap();
135        assert_eq!(result.user, "");
136        assert_eq!(result.hostname, "example.com");
137        assert_eq!(result.port, 8022);
138    }
139
140    #[test]
141    fn test_host_only() {
142        let result = parse_target("example.com").unwrap();
143        assert_eq!(result.user, "");
144        assert_eq!(result.hostname, "example.com");
145        assert_eq!(result.port, 22);
146    }
147
148    #[test]
149    fn test_empty_target() {
150        assert!(parse_target("").is_err());
151    }
152
153    #[test]
154    fn test_empty_user() {
155        assert!(parse_target("@example.com").is_err());
156    }
157
158    #[test]
159    fn test_empty_hostname() {
160        assert!(parse_target("user@").is_err());
161    }
162
163    #[test]
164    fn test_port_zero() {
165        assert!(parse_target("example.com:0").is_err());
166    }
167
168    #[test]
169    fn test_invalid_port_text() {
170        assert!(parse_target("example.com:abc").is_err());
171    }
172
173    #[test]
174    fn test_trailing_colon() {
175        assert!(parse_target("example.com:").is_err());
176    }
177
178    #[test]
179    fn test_port_overflow() {
180        assert!(parse_target("example.com:99999").is_err());
181    }
182
183    #[test]
184    fn test_looks_like_target_with_at() {
185        assert!(looks_like_target("user@host"));
186    }
187
188    #[test]
189    fn test_looks_like_target_with_port() {
190        assert!(looks_like_target("host:22"));
191    }
192
193    #[test]
194    fn test_looks_like_target_plain_host() {
195        assert!(!looks_like_target("myserver"));
196    }
197
198    #[test]
199    fn test_bare_ipv6() {
200        let result = parse_target("2001:db8::1").unwrap();
201        assert_eq!(result.hostname, "2001:db8::1");
202        assert_eq!(result.port, 22);
203    }
204
205    #[test]
206    fn test_bracketed_ipv6_with_port() {
207        let result = parse_target("[2001:db8::1]:2222").unwrap();
208        assert_eq!(result.hostname, "2001:db8::1");
209        assert_eq!(result.port, 2222);
210    }
211
212    #[test]
213    fn test_bracketed_ipv6_no_port() {
214        let result = parse_target("[::1]").unwrap();
215        assert_eq!(result.hostname, "::1");
216        assert_eq!(result.port, 22);
217    }
218
219    #[test]
220    fn test_user_at_ipv6() {
221        let result = parse_target("root@2001:db8::1").unwrap();
222        assert_eq!(result.user, "root");
223        assert_eq!(result.hostname, "2001:db8::1");
224        assert_eq!(result.port, 22);
225    }
226
227    #[test]
228    fn test_looks_like_target_bare_ipv6() {
229        // Bare IPv6 without @ should NOT look like a target (would be ambiguous)
230        assert!(!looks_like_target("2001:db8::1"));
231    }
232
233    #[test]
234    fn test_looks_like_target_bracketed_ipv6() {
235        assert!(looks_like_target("[::1]:22"));
236    }
237
238    #[test]
239    fn test_hostname_with_space() {
240        assert!(parse_target("bad host").is_err());
241    }
242
243    #[test]
244    fn test_hostname_with_control_char() {
245        assert!(parse_target("bad\x00host").is_err());
246        assert!(parse_target("bad\nhost").is_err());
247    }
248
249    #[test]
250    fn test_user_with_space() {
251        assert!(parse_target("bad user@host").is_err());
252    }
253
254    #[test]
255    fn test_user_with_control_char() {
256        assert!(parse_target("bad\x01user@host").is_err());
257    }
258}