use std::path::PathBuf;
use std::time::Duration;
use theway_core::AgentToolError;
use tokio_util::sync::CancellationToken;
use super::{HTTP_TIMEOUT_SECS, SKILL_FETCH_OOM_GUARD_BYTES, Source};
#[cfg(test)]
#[path = "../../../tests/tools/install_skill/fetch/mock_reqwest.rs"]
mod reqwest;
pub(super) struct Fetched {
pub(super) content: String,
}
pub(super) async fn fetch_source(
source: &Source,
cancel: &CancellationToken,
) -> Result<Fetched, AgentToolError> {
match source {
Source::Url { url } => fetch_url(url, cancel).await,
Source::Path { path } => fetch_path(path).await,
Source::Content { content } => Ok(fetch_inline(content)),
}
}
async fn fetch_url(url: &str, cancel: &CancellationToken) -> Result<Fetched, AgentToolError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| AgentToolError::Message(format!("invalid url: {e}")))?;
if parsed.scheme() != "https" {
return Err(AgentToolError::Message(
"url must use https:// (http, file, data, and other schemes are refused)".into(),
));
}
let host = parsed
.host_str()
.ok_or_else(|| AgentToolError::from("url must have a host"))?;
if is_private_or_local_host(host) {
return Err(AgentToolError::Message(format!(
"refusing to fetch from local/private host '{host}' (SSRF guard)"
)));
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent(format!("theway/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| AgentToolError::Message(format!("http client init: {e}")))?;
let fut = client.get(parsed).send();
let mut resp = tokio::select! {
r = fut => r.map_err(|e| AgentToolError::Message(format!("fetch failed: {e}")))?,
_ = cancel.cancelled() => return Err(AgentToolError::Message("cancelled".into())),
};
if !resp.status().is_success() {
return Err(AgentToolError::Message(format!(
"fetch returned non-success status: {}",
resp.status()
)));
}
let mut buf = Vec::<u8>::new();
loop {
let chunk = tokio::select! {
r = resp.chunk() => r,
_ = cancel.cancelled() => return Err(AgentToolError::Message("cancelled".into())),
};
match chunk {
Ok(Some(c)) => {
if buf.len() + c.len() > SKILL_FETCH_OOM_GUARD_BYTES {
return Err(AgentToolError::Message(format!(
"fetched skill body exceeds {SKILL_FETCH_OOM_GUARD_BYTES}-byte \
in-memory guard ({} bytes received so far); refusing to install \
from a stream this large",
buf.len()
)));
}
buf.extend_from_slice(&c);
}
Ok(None) => break,
Err(e) => {
return Err(AgentToolError::Message(format!("read body: {e}")));
}
}
}
let content = String::from_utf8(buf)
.map_err(|e| AgentToolError::Message(format!("skill body is not valid utf-8: {e}")))?;
Ok(Fetched { content })
}
async fn fetch_path(path: &str) -> Result<Fetched, AgentToolError> {
let p = PathBuf::from(path);
if !p.is_absolute() {
return Err(AgentToolError::from(
"path must be absolute (relative paths are ambiguous in agent context)",
));
}
let meta = tokio::fs::metadata(&p)
.await
.map_err(|e| AgentToolError::Message(format!("stat {}: {e}", p.display())))?;
if !meta.is_file() {
return Err(AgentToolError::Message(format!(
"{} is not a regular file",
p.display()
)));
}
if meta.len() as usize > SKILL_FETCH_OOM_GUARD_BYTES {
return Err(AgentToolError::Message(format!(
"{} ({} bytes) exceeds {SKILL_FETCH_OOM_GUARD_BYTES}-byte in-memory guard",
p.display(),
meta.len()
)));
}
let content = tokio::fs::read_to_string(&p)
.await
.map_err(|e| AgentToolError::Message(format!("read {}: {e}", p.display())))?;
Ok(Fetched { content })
}
fn fetch_inline(content: &str) -> Fetched {
Fetched {
content: content.to_string(),
}
}
fn is_private_or_local_host(host: &str) -> bool {
let host_lower = host
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
if matches!(
host_lower.as_str(),
"localhost" | "ip6-localhost" | "ip6-loopback" | "broadcasthost"
) {
return true;
}
if host_lower.ends_with(".localhost") || host_lower.ends_with(".local") {
return true;
}
if let Ok(ip) = host_lower.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.is_unspecified() || v6.segments()[0] & 0xfe00 == 0xfc00
}
};
}
false
}
#[cfg(test)]
tests_bridge_macro::tests_bridge!("tools/install_skill/fetch");
#[cfg(test)]
mod fetch_extra {
tests_bridge_macro::tests_bridge!("tools/install_skill/fetch/extra");
}
#[cfg(test)]
mod fetch_mock_success {
tests_bridge_macro::tests_bridge!("tools/install_skill/fetch/mock_success");
}