use url::Url;
use crate::services::ai::error_sanitizer::sanitize_url_in_error;
use crate::services::ai::security::local_provider_hint;
pub(crate) fn is_private_host(url: &Url) -> bool {
let Some(host) = url.host_str() else {
return false;
};
is_private_host_str(host)
}
pub(crate) fn is_private_host_str(host: &str) -> bool {
let host = host.trim().trim_start_matches('[').trim_end_matches(']');
if let Ok(v4) = host.parse::<std::net::Ipv4Addr>() {
return is_private_ipv4(v4);
}
if let Ok(v6) = host.parse::<std::net::Ipv6Addr>() {
return is_private_ipv6(v6);
}
let lower = host.to_ascii_lowercase();
if lower == "localhost" {
return true;
}
for suffix in [".local", ".lan", ".internal", ".localdomain", ".home.arpa"] {
if lower.ends_with(suffix) {
return true;
}
}
false
}
fn is_private_ipv4(addr: std::net::Ipv4Addr) -> bool {
if addr.is_loopback() {
return true;
}
if addr.is_private() {
return true;
}
if addr.is_link_local() {
return true;
}
false
}
fn is_private_ipv6(addr: std::net::Ipv6Addr) -> bool {
if addr.is_loopback() {
return true;
}
let segments = addr.segments();
if (segments[0] & 0xfe00) == 0xfc00 {
return true;
}
if (segments[0] & 0xffc0) == 0xfe80 {
return true;
}
false
}
pub(crate) fn should_hint_for_transport(err: &reqwest::Error, configured_url: &str) -> bool {
if !(err.is_connect() || err.is_request() || err.is_timeout()) {
return false;
}
let Ok(url) = Url::parse(configured_url) else {
return false;
};
is_private_host(&url)
}
pub(crate) fn should_hint_for_parse(body_was_json: bool, _parse_error_msg: &str) -> bool {
body_was_json
}
pub(crate) fn append_local_hint(message: &str) -> String {
let sanitized = sanitize_url_in_error(message);
format!("{}\n{}", sanitized, local_provider_hint())
}
pub(crate) fn maybe_attach_local_hint(
err: crate::error::SubXError,
configured_url: &str,
) -> crate::error::SubXError {
use crate::error::SubXError;
let Ok(url) = Url::parse(configured_url) else {
return err;
};
if !is_private_host(&url) {
return err;
}
match err {
SubXError::AiService(msg) => SubXError::AiService(append_local_hint(&msg)),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn is_private_host_loopback_v4() {
assert!(is_private_host(&url("http://127.0.0.1:8080/v1")));
assert!(is_private_host(&url("http://127.255.255.254/v1")));
}
#[test]
fn is_private_host_loopback_v6() {
assert!(is_private_host(&url("http://[::1]:8080/v1")));
}
#[test]
fn is_private_host_rfc1918() {
assert!(is_private_host(&url("http://10.0.0.5:11434/v1")));
assert!(is_private_host(&url("http://172.16.0.1/v1")));
assert!(is_private_host(&url("http://172.31.255.255/v1")));
assert!(is_private_host(&url("http://192.168.0.1/v1")));
assert!(is_private_host(&url("http://192.168.255.255/v1")));
}
#[test]
fn is_private_host_link_local() {
assert!(is_private_host(&url("http://169.254.1.1/v1")));
assert!(is_private_host(&url("http://[fe80::1]/v1")));
assert!(is_private_host(&url("http://[febf::1]/v1")));
}
#[test]
fn is_private_host_rfc4193() {
assert!(is_private_host(&url("http://[fc00::1]/v1")));
assert!(is_private_host(&url("http://[fdff::1]/v1")));
}
#[test]
fn is_private_host_hostname_aliases() {
assert!(is_private_host(&url("http://localhost:11434/v1")));
assert!(is_private_host(&url("http://my-box.local/v1")));
assert!(is_private_host(&url("http://server.lan/v1")));
assert!(is_private_host(&url("http://gpu.internal/v1")));
assert!(is_private_host(&url("http://x.localdomain/v1")));
}
#[test]
fn is_private_host_public_addresses_negative() {
assert!(!is_private_host(&url("https://api.openai.com/v1")));
assert!(!is_private_host(&url("https://1.1.1.1/v1")));
assert!(!is_private_host(&url("https://8.8.8.8/v1")));
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")));
}
#[test]
fn is_private_host_str_handles_bracketed_v6() {
assert!(is_private_host_str("[::1]"));
assert!(is_private_host_str("::1"));
}
#[test]
fn should_hint_for_parse_only_when_body_was_json() {
assert!(should_hint_for_parse(true, "missing field"));
assert!(!should_hint_for_parse(false, "expected value"));
}
#[test]
fn append_local_hint_appends_full_advisory_and_strips_query() {
let appended = append_local_hint("oops at https://x.test/a?token=secret");
assert!(!appended.contains("token=secret"));
assert!(appended.contains("oops at https://x.test/a"));
assert!(
appended.contains("ai.provider"),
"missing canonical hint: {appended}"
);
assert!(appended.contains("ollama"));
assert!(appended.contains('\n'));
}
#[test]
fn append_local_hint_uses_canonical_helper() {
let appended = append_local_hint("x");
assert!(appended.ends_with(local_provider_hint()));
}
#[test]
fn maybe_attach_local_hint_appends_when_url_private() {
use crate::error::SubXError;
let err = SubXError::AiService("connection refused".to_string());
let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1:11434/v1");
let msg = wrapped.to_string();
assert!(msg.contains("connection refused"));
assert!(msg.contains("ollama"), "missing canonical hint: {msg}");
assert!(msg.contains("ai.provider"));
}
#[test]
fn maybe_attach_local_hint_skips_when_url_public() {
use crate::error::SubXError;
let err = SubXError::AiService("HTTP 401".to_string());
let wrapped = maybe_attach_local_hint(err, "https://api.openai.com/v1");
let msg = wrapped.to_string();
assert!(msg.contains("HTTP 401"));
assert!(
!msg.contains("ollama"),
"hint must not be emitted for public hosts: {msg}"
);
}
#[test]
fn maybe_attach_local_hint_skips_when_url_unparseable() {
use crate::error::SubXError;
let err = SubXError::AiService("boom".to_string());
let wrapped = maybe_attach_local_hint(err, "not a url");
assert!(!wrapped.to_string().contains("ollama"));
}
#[test]
fn maybe_attach_local_hint_passthrough_for_non_ai_service_errors() {
use crate::error::SubXError;
let err = SubXError::config("bad");
let wrapped = maybe_attach_local_hint(err, "http://127.0.0.1/v1");
assert!(matches!(wrapped, SubXError::Config { .. }));
}
}