subx_cli/services/ai/
hosted_hint.rs1use url::Url;
14
15use crate::services::ai::error_sanitizer::sanitize_url_in_error;
16use crate::services::ai::security::local_provider_hint;
17
18pub(crate) fn is_private_host(url: &Url) -> bool {
27 let Some(host) = url.host_str() else {
28 return false;
29 };
30 is_private_host_str(host)
31}
32
33pub(crate) fn is_private_host_str(host: &str) -> bool {
37 let host = host.trim().trim_start_matches('[').trim_end_matches(']');
40
41 if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
43 return is_private_ipv4(v4);
44 }
45 if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
47 return is_private_ipv6(v6);
48 }
49
50 let lower = host.to_ascii_lowercase();
52 if lower == "localhost" {
53 return true;
54 }
55 for suffix in [".local", ".lan", ".internal", ".localdomain", ".home.arpa"] {
57 if lower.ends_with(suffix) {
58 return true;
59 }
60 }
61 false
62}
63
64fn is_private_ipv4(addr: std::net::Ipv4Addr) -> bool {
65 if addr.is_loopback() {
67 return true;
68 }
69 if addr.is_private() {
71 return true;
72 }
73 if addr.is_link_local() {
75 return true;
76 }
77 false
78}
79
80fn is_private_ipv6(addr: std::net::Ipv6Addr) -> bool {
81 if addr.is_loopback() {
83 return true;
84 }
85 let segments = addr.segments();
86 if (segments[0] & 0xfe00) == 0xfc00 {
89 return true;
90 }
91 if (segments[0] & 0xffc0) == 0xfe80 {
94 return true;
95 }
96 false
97}
98
99pub(crate) fn should_hint_for_transport(err: &reqwest::Error, configured_url: &str) -> bool {
115 if !(err.is_connect() || err.is_request() || err.is_timeout()) {
118 return false;
119 }
120 let Ok(url) = Url::parse(configured_url) else {
121 return false;
122 };
123 is_private_host(&url)
124}
125
126pub(crate) fn should_hint_for_parse(body_was_json: bool, _parse_error_msg: &str) -> bool {
138 body_was_json
139}
140
141pub(crate) fn append_local_hint(message: &str) -> String {
146 let sanitized = sanitize_url_in_error(message);
147 format!("{}\n{}", sanitized, local_provider_hint())
148}
149
150pub(crate) fn maybe_attach_local_hint(
171 err: crate::error::SubXError,
172 configured_url: &str,
173) -> crate::error::SubXError {
174 use crate::error::SubXError;
175 let Ok(url) = Url::parse(configured_url) else {
176 return err;
177 };
178 if !is_private_host(&url) {
179 return err;
180 }
181 match err {
182 SubXError::AiService(msg) => SubXError::AiService(append_local_hint(&msg)),
183 other => other,
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn url(s: &str) -> Url {
192 Url::parse(s).unwrap()
193 }
194
195 #[test]
196 fn is_private_host_loopback_v4() {
197 assert!(is_private_host(&url("http://127.0.0.1:8080/v1")));
198 assert!(is_private_host(&url("http://127.255.255.254/v1")));
199 }
200
201 #[test]
202 fn is_private_host_loopback_v6() {
203 assert!(is_private_host(&url("http://[::1]:8080/v1")));
204 }
205
206 #[test]
207 fn is_private_host_rfc1918() {
208 assert!(is_private_host(&url("http://10.0.0.5:11434/v1")));
209 assert!(is_private_host(&url("http://172.16.0.1/v1")));
210 assert!(is_private_host(&url("http://172.31.255.255/v1")));
211 assert!(is_private_host(&url("http://192.168.0.1/v1")));
212 assert!(is_private_host(&url("http://192.168.255.255/v1")));
213 }
214
215 #[test]
216 fn is_private_host_link_local() {
217 assert!(is_private_host(&url("http://169.254.1.1/v1")));
218 assert!(is_private_host(&url("http://[fe80::1]/v1")));
219 assert!(is_private_host(&url("http://[febf::1]/v1")));
220 }
221
222 #[test]
223 fn is_private_host_rfc4193() {
224 assert!(is_private_host(&url("http://[fc00::1]/v1")));
225 assert!(is_private_host(&url("http://[fdff::1]/v1")));
226 }
227
228 #[test]
229 fn is_private_host_hostname_aliases() {
230 assert!(is_private_host(&url("http://localhost:11434/v1")));
231 assert!(is_private_host(&url("http://my-box.local/v1")));
232 assert!(is_private_host(&url("http://server.lan/v1")));
233 assert!(is_private_host(&url("http://gpu.internal/v1")));
234 assert!(is_private_host(&url("http://x.localdomain/v1")));
235 }
236
237 #[test]
238 fn is_private_host_public_addresses_negative() {
239 assert!(!is_private_host(&url("https://api.openai.com/v1")));
240 assert!(!is_private_host(&url("https://1.1.1.1/v1")));
241 assert!(!is_private_host(&url("https://8.8.8.8/v1")));
242 assert!(!is_private_host(&url("https://172.32.0.1/v1"))); assert!(!is_private_host(&url("https://192.169.0.1/v1"))); assert!(!is_private_host(&url("https://[2001:4860:4860::8888]/v1")));
245 }
246
247 #[test]
248 fn is_private_host_str_handles_bracketed_v6() {
249 assert!(is_private_host_str("[::1]"));
250 assert!(is_private_host_str("::1"));
251 }
252
253 #[test]
254 fn should_hint_for_parse_only_when_body_was_json() {
255 assert!(should_hint_for_parse(true, "missing field"));
256 assert!(!should_hint_for_parse(false, "expected value"));
257 }
258
259 #[test]
260 fn append_local_hint_appends_full_advisory_and_strips_query() {
261 let appended = append_local_hint("oops at https://x.test/a?token=secret");
262 assert!(!appended.contains("token=secret"));
264 assert!(appended.contains("oops at https://x.test/a"));
266 assert!(
268 appended.contains("ai.provider"),
269 "missing canonical hint: {appended}"
270 );
271 assert!(appended.contains("ollama"));
272 assert!(appended.contains('\n'));
273 }
274
275 #[test]
276 fn append_local_hint_uses_canonical_helper() {
277 let appended = append_local_hint("x");
279 assert!(appended.ends_with(local_provider_hint()));
280 }
281
282 #[test]
290 fn maybe_attach_local_hint_appends_when_url_private() {
291 use crate::error::SubXError;
292 let err = SubXError::AiService("connection refused".to_string());
293 let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1:11434/v1");
294 let msg = wrapped.to_string();
295 assert!(msg.contains("connection refused"));
296 assert!(msg.contains("ollama"), "missing canonical hint: {msg}");
297 assert!(msg.contains("ai.provider"));
298 }
299
300 #[test]
301 fn maybe_attach_local_hint_skips_when_url_public() {
302 use crate::error::SubXError;
303 let err = SubXError::AiService("HTTP 401".to_string());
304 let wrapped = maybe_attach_local_hint(err, "https://api.openai.com/v1");
305 let msg = wrapped.to_string();
306 assert!(msg.contains("HTTP 401"));
307 assert!(
308 !msg.contains("ollama"),
309 "hint must not be emitted for public hosts: {msg}"
310 );
311 }
312
313 #[test]
314 fn maybe_attach_local_hint_skips_when_url_unparseable() {
315 use crate::error::SubXError;
316 let err = SubXError::AiService("boom".to_string());
317 let wrapped = maybe_attach_local_hint(err, "not a url");
318 assert!(!wrapped.to_string().contains("ollama"));
319 }
320
321 #[test]
322 fn maybe_attach_local_hint_passthrough_for_non_ai_service_errors() {
323 use crate::error::SubXError;
324 let err = SubXError::config("bad");
325 let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1/v1");
326 assert!(matches!(wrapped, SubXError::Config { .. }));
328 }
329}