use std::net::IpAddr;
use promptforge_core::tools::ToolErrorKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Disposition {
SoftOutput,
Hard(ToolErrorKind),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SafeUrl(String);
impl SafeUrl {
#[must_use]
pub(crate) fn new(raw: &str) -> SafeUrl {
match url::Url::parse(raw) {
Ok(parsed) => {
let scheme = parsed.scheme();
let host = parsed.host_str().unwrap_or_default();
let mut out = format!("{scheme}://{host}");
if let Some(port) = parsed.port() {
out.push(':');
out.push_str(&port.to_string());
}
out.push_str(parsed.path());
SafeUrl(out)
}
Err(_) => SafeUrl(raw.to_string()),
}
}
}
impl std::fmt::Display for SafeUrl {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum FetchError {
#[error("invalid url")]
InvalidUrl(#[source] url::ParseError),
#[error("scheme not allowed: {0}")]
BlockedScheme(String),
#[error("url must not contain userinfo")]
Userinfo,
#[error("port not allowed: {0}")]
BlockedPort(u16),
#[error("ip literal host not allowed: {0}")]
IpLiteral(String),
#[error("host {host} resolved to blocked address {addr} in range {range}")]
BlockedAddress {
host: String,
addr: IpAddr,
range: String,
},
#[error("host {host} has no allowed address")]
NoAllowedAddress {
host: String,
},
#[error("redirect from {from} to {to} refused: {reason}")]
RedirectRefused {
from: SafeUrl,
to: SafeUrl,
reason: String,
},
#[error("response from {url} exceeds the {limit}-byte size cap")]
TooLarge {
url: SafeUrl,
limit: usize,
},
#[error("failed to read the response body from {url}; try again or use a different URL")]
BodyRead {
url: SafeUrl,
#[source]
source: reqwest::Error,
},
#[error("dns resolution failed for {host}")]
Dns {
host: String,
#[source]
source: std::io::Error,
},
#[error(
"content type {content_type} from {url} cannot be returned as text; try an HTML version of the page or a different URL"
)]
UnsupportedContentType {
url: SafeUrl,
content_type: String,
},
#[error(
"response from {url} declared no content type; refusing to guess its format; try a different URL"
)]
NoContentType {
url: SafeUrl,
},
#[error("request to {url} timed out; try again or use a different URL")]
Timeout {
url: SafeUrl,
},
#[error("response from {url} declared unknown charset {charset}; cannot decode its text")]
Undecodable {
url: SafeUrl,
charset: String,
},
#[error("HTTP {status} from {url}; try a different URL")]
HttpStatus {
url: SafeUrl,
status: u16,
},
}
impl FetchError {
#[must_use]
pub(crate) fn model_facing(&self) -> String {
match self {
FetchError::BlockedAddress { host, .. } => {
format!("host {host} is not fetchable")
}
other => other.to_string(),
}
}
#[must_use]
pub(crate) fn classify(&self) -> Disposition {
match self {
FetchError::HttpStatus { .. }
| FetchError::UnsupportedContentType { .. }
| FetchError::NoContentType { .. }
| FetchError::Timeout { .. }
| FetchError::TooLarge { .. }
| FetchError::BodyRead { .. }
| FetchError::Undecodable { .. }
| FetchError::Dns { .. }
| FetchError::RedirectRefused { .. }
| FetchError::BlockedScheme(_) => Disposition::SoftOutput,
FetchError::InvalidUrl(_)
| FetchError::Userinfo
| FetchError::BlockedPort(_)
| FetchError::IpLiteral(_)
| FetchError::BlockedAddress { .. }
| FetchError::NoAllowedAddress { .. } => {
Disposition::Hard(ToolErrorKind::InvalidArguments)
}
}
}
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use std::net::IpAddr;
use promptforge_core::tools::ToolErrorKind;
use super::{Disposition, FetchError, SafeUrl};
#[test]
fn blocked_address_log_keeps_detail_model_facing_hides_it() {
let addr: IpAddr = "169.254.169.254".parse().expect("test address parses");
let err = FetchError::BlockedAddress {
host: "metadata.internal".to_string(),
addr,
range: "169.254.0.0/16".to_string(),
};
let log = err.to_string();
assert!(log.contains("169.254.169.254"), "log must name the address");
assert!(log.contains("169.254.0.0/16"), "log must name the range");
let facing = err.model_facing();
assert!(
!facing.contains("169.254.169.254") && !facing.contains("169.254.0.0/16"),
"model-facing text must hide the address and range, got: {facing}"
);
assert!(
facing.contains("metadata.internal") && facing.contains("not fetchable"),
"model-facing text should name the host as not fetchable, got: {facing}"
);
}
async fn reqwest_error() -> reqwest::Error {
reqwest::Client::builder()
.https_only(true)
.build()
.expect("client builds")
.get("http://example.invalid/")
.send()
.await
.expect_err("an http url must be rejected by an https-only client")
}
#[test]
fn disposition_table() {
let parse = url::Url::parse("not a url").expect_err("must fail to parse");
let soft: [FetchError; 9] = [
FetchError::HttpStatus {
url: SafeUrl::new("https://u/"),
status: 404,
},
FetchError::UnsupportedContentType {
url: SafeUrl::new("https://u/"),
content_type: "application/pdf".into(),
},
FetchError::NoContentType {
url: SafeUrl::new("https://u/"),
},
FetchError::Timeout {
url: SafeUrl::new("https://u/"),
},
FetchError::TooLarge {
url: SafeUrl::new("https://u/"),
limit: 100,
},
FetchError::Undecodable {
url: SafeUrl::new("https://u/"),
charset: "x".into(),
},
FetchError::Dns {
host: "h".into(),
source: std::io::Error::other("resolver down"),
},
FetchError::RedirectRefused {
from: SafeUrl::new("https://a/"),
to: SafeUrl::new("http://a/"),
reason: "downgrade".into(),
},
FetchError::BlockedScheme("http".into()),
];
for err in &soft {
assert_eq!(
err.classify(),
Disposition::SoftOutput,
"{err} must be soft"
);
}
let hard: [FetchError; 6] = [
FetchError::InvalidUrl(parse),
FetchError::Userinfo,
FetchError::BlockedPort(22),
FetchError::IpLiteral("1.2.3.4".into()),
FetchError::BlockedAddress {
host: "h".into(),
addr: "127.0.0.1".parse().expect("loopback parses"),
range: "127.0.0.0/8".into(),
},
FetchError::NoAllowedAddress { host: "h".into() },
];
for err in &hard {
assert_eq!(
err.classify(),
Disposition::Hard(ToolErrorKind::InvalidArguments),
"{err} must be hard invalid-arguments"
);
}
}
#[tokio::test]
async fn body_read_error_is_soft_with_reachable_source() {
let err = FetchError::BodyRead {
url: SafeUrl::new("https://u/"),
source: reqwest_error().await,
};
assert_eq!(
err.classify(),
Disposition::SoftOutput,
"BodyRead must be soft"
);
assert!(err.source().is_some(), "BodyRead must expose its cause");
}
#[test]
fn url_parse_and_dns_errors_keep_a_reachable_source() {
let parse = url::Url::parse("not a url").expect_err("must fail to parse");
let err = FetchError::InvalidUrl(parse);
assert!(err.source().is_some(), "InvalidUrl must expose its cause");
let io = std::io::Error::other("resolver down");
let err = FetchError::Dns {
host: "h".into(),
source: io,
};
assert!(err.source().is_some(), "Dns must expose its cause");
}
#[test]
fn secret_query_is_redacted_in_debug_and_display() {
const SECRET: &str = "supersecrettoken";
let leaky = format!("https://host.example/path?token={SECRET}#frag");
let variants: [FetchError; 6] = [
FetchError::TooLarge {
url: SafeUrl::new(&leaky),
limit: 1,
},
FetchError::Timeout {
url: SafeUrl::new(&leaky),
},
FetchError::HttpStatus {
url: SafeUrl::new(&leaky),
status: 500,
},
FetchError::NoContentType {
url: SafeUrl::new(&leaky),
},
FetchError::UnsupportedContentType {
url: SafeUrl::new(&leaky),
content_type: "application/pdf".into(),
},
FetchError::RedirectRefused {
from: SafeUrl::new(&leaky),
to: SafeUrl::new(&leaky),
reason: "downgrade".into(),
},
];
for err in &variants {
let debug = format!("{err:?}");
let display = err.to_string();
let facing = err.model_facing();
assert!(!debug.contains(SECRET), "secret leaked in Debug: {debug}");
assert!(
!display.contains(SECRET),
"secret leaked in Display: {display}"
);
assert!(
!facing.contains(SECRET),
"secret leaked in model_facing: {facing}"
);
assert!(
display.contains("host.example"),
"host must survive: {display}"
);
}
}
#[test]
fn safe_url_redacts_userinfo_query_and_fragment() {
let safe = SafeUrl::new("https://user:pass@host.example:8443/a/b?x=secret#frag");
assert_eq!(safe.to_string(), "https://host.example:8443/a/b");
}
}