use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
pub(crate) fn install_ring_provider() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
static MEDIA_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
static IMAGE_GEN_HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
#[must_use]
pub fn bearer_auth_header() -> Option<String> {
let key = crate::config::CONFIG.provider_key()?;
if key.is_empty() {
return None;
}
Some(format!("Bearer {key}"))
}
pub(crate) async fn read_error_body(response: reqwest::Response, context: &str) -> String {
response.text().await.unwrap_or_else(|e| {
tracing::warn!(?e, "Failed to read {context} response body");
"failed to read response body".to_string()
})
}
async fn check_response(
response: reqwest::Response,
error_context: &str,
) -> anyhow::Result<reqwest::Response> {
if !response.status().is_success() {
let mut err = super::error::HttpError::from_response(response, error_context).await;
err.body = crate::util::truncate(&err.body, 500);
return Err(anyhow::Error::from(err));
}
Ok(response)
}
pub(crate) fn parse_json_response(
body_text: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
serde_json::from_str(body_text).map_err(|e| {
anyhow::anyhow!(
"{error_context} response parse error: {e}\nraw response body ({}): {body_text:.500}",
body_text.len(),
)
})
}
async fn provider_request(
error_context: &str,
build_request: impl FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::Response> {
let auth = bearer_auth_header()
.ok_or_else(|| anyhow::anyhow!("{error_context}: provider API key is not configured"))?;
let client = media_http_client();
let response = build_request(client)
.header("Authorization", &auth)
.send()
.await
.map_err(|e| anyhow::anyhow!("{error_context} request failed: {e}"))?;
check_response(response, error_context).await
}
pub async fn post_json_to_provider(
url: &str,
body: &serde_json::Value,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.post(url).json(body)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_json_from_provider(
url: &str,
error_context: &str,
) -> anyhow::Result<serde_json::Value> {
let response = provider_request(error_context, |client| client.get(url)).await?;
let body_text = response
.text()
.await
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))?;
parse_json_response(&body_text, error_context)
}
pub async fn get_bytes_from_provider(
url: &str,
error_context: &str,
request_timeout: Duration,
) -> anyhow::Result<Vec<u8>> {
let response = provider_request(error_context, |client| {
client.get(url).timeout(request_timeout)
})
.await?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| anyhow::anyhow!("{error_context} failed to read response body: {e}"))
}
#[must_use]
pub fn media_http_client() -> &'static reqwest::Client {
MEDIA_HTTP_CLIENT.get_or_init(|| build_http_client(Duration::from_mins(2)))
}
#[must_use]
pub fn image_gen_http_client() -> &'static reqwest::Client {
IMAGE_GEN_HTTP_CLIENT.get_or_init(|| build_http_client(Duration::from_mins(10)))
}
#[must_use]
pub fn build_http_client(timeout: Duration) -> reqwest::Client {
build_http_client_with(timeout, reqwest::redirect::Policy::default())
}
#[must_use]
pub fn build_no_redirect_http_client(timeout: Duration) -> reqwest::Client {
build_http_client_with(timeout, reqwest::redirect::Policy::none())
}
fn build_http_client_with(
timeout: Duration,
redirect: reqwest::redirect::Policy,
) -> reqwest::Client {
install_ring_provider();
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.redirect(redirect)
.build()
.expect("Failed to build HTTP client (rustls TLS initialization failure)")
}
static DENIED_IP_NETS: std::sync::LazyLock<[ipnet::IpNet; 4]> = std::sync::LazyLock::new(|| {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
[
ipnet::IpNet::new(IpAddr::V4(Ipv4Addr::new(169, 254, 0, 0)), 16)
.expect("169.254.0.0/16 is a valid IPv4 net"),
ipnet::IpNet::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8)
.expect("0.0.0.0/8 is a valid IPv4 net"),
ipnet::IpNet::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 128)
.expect("::1/128 is a valid IPv6 net"),
ipnet::IpNet::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0, 0)), 96)
.expect("::ffff:0:0/96 is a valid IPv6 net"),
]
});
pub(crate) fn validate_url(url: &str) -> anyhow::Result<()> {
use url::Host;
let url = url.trim();
if url.is_empty() {
anyhow::bail!("URL cannot be empty");
}
if url.to_ascii_lowercase().starts_with("file://") {
anyhow::bail!("file:// URLs are not allowed");
}
let parsed = url::Url::parse(url)
.map_err(|_| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?;
if !matches!(parsed.scheme(), "http" | "https") {
anyhow::bail!("Only http:// and https:// URLs are allowed");
}
let Some(host) = parsed.host() else {
return Ok(());
};
let denied = |host: url::Host<&str>| {
anyhow::anyhow!("host {host} is a denied link-local/metadata address (SSRF guard)")
};
let ip = match host {
Host::Domain(domain) => {
if domain.eq_ignore_ascii_case("metadata.google.internal") {
return Err(denied(host));
}
None
}
Host::Ipv4(addr) => Some(std::net::IpAddr::V4(addr)),
Host::Ipv6(addr) => Some(std::net::IpAddr::V6(addr)),
};
if ip.is_some_and(|ip| DENIED_IP_NETS.iter().any(|net| net.contains(&ip))) {
return Err(denied(host));
}
Ok(())
}
pub(crate) fn redact_address(value: &str) -> String {
let Ok(mut parsed) = url::Url::parse(value) else {
return value.to_string();
};
if !matches!(parsed.scheme(), "http" | "https") {
return value.to_string();
}
parsed.set_query(None);
parsed.set_fragment(None);
parsed.to_string()
}
const MAX_GUARDED_REDIRECTS: usize = 5;
pub(crate) async fn get_guarded(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<reqwest::Response> {
use anyhow::Context as _;
let mut next = url.trim().to_string();
for _ in 0..=MAX_GUARDED_REDIRECTS {
validate_url(&next)
.with_context(|| format!("refusing to fetch {}", redact_address(&next)))?;
let response = client
.get(&next)
.send()
.await
.map_err(reqwest::Error::without_url)?;
if !response.status().is_redirection() {
return Ok(response);
}
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.context("a redirect answered with no location")?;
next = url::Url::parse(&next)
.ok()
.and_then(|base| base.join(location).ok())
.map(|target| target.to_string())
.context("a redirect named an address that cannot be resolved")?;
}
anyhow::bail!(
"{} redirected more than {MAX_GUARDED_REDIRECTS} times",
redact_address(url)
)
}
pub(crate) fn build_download_client(total_timeout: Duration) -> anyhow::Result<reqwest::Client> {
install_ring_provider();
reqwest::Client::builder()
.timeout(total_timeout)
.connect_timeout(Duration::from_secs(30))
.build()
.map_err(anyhow::Error::from)
}
pub(crate) enum DownloadSizeCheck {
Exact,
#[cfg_attr(not(target_os = "macos"), expect(dead_code))]
Min(u64),
None,
}
#[derive(Debug)]
pub(crate) struct ChecksumMismatch;
impl std::fmt::Display for ChecksumMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("the downloaded file does not match the checksum published with the release")
}
}
impl std::error::Error for ChecksumMismatch {}
fn temp_sibling(dest: &Path) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut tmp = dest.as_os_str().to_owned();
tmp.push(format!(
".part-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
PathBuf::from(tmp)
}
fn sweep_abandoned_parts(dest: &Path) {
let (Some(dir), Some(prefix)) = (
dest.parent(),
dest.file_name().and_then(|name| name.to_str()),
) else {
return;
};
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let pid = name
.to_str()
.and_then(|name| name.strip_prefix(prefix))
.and_then(|rest| rest.strip_prefix(".part-"))
.and_then(|rest| rest.split_once('-'))
.and_then(|(pid, _seq)| pid.parse::<u32>().ok());
if let Some(pid) = pid
&& pid != std::process::id()
&& !crate::util::process_alive(pid)
{
let _ = std::fs::remove_file(entry.path());
}
}
}
struct TempDownload {
path: PathBuf,
disarmed: bool,
}
impl TempDownload {
fn new(path: PathBuf) -> Self {
Self {
path,
disarmed: false,
}
}
fn path(&self) -> &Path {
&self.path
}
fn disarm(&mut self) {
self.disarmed = true;
}
}
impl Drop for TempDownload {
fn drop(&mut self) {
if !self.disarmed {
let _ = std::fs::remove_file(&self.path);
}
}
}
pub(crate) async fn download_verified(
client: &reqwest::Client,
url: &str,
dest: &Path,
expected_sha256: &str,
timeout: Option<Duration>,
size_check: DownloadSizeCheck,
mut progress: impl FnMut(u64, u64),
) -> anyhow::Result<()> {
use anyhow::Context as _;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
let mut request = client.get(url);
if let Some(t) = timeout {
request = request.timeout(t);
}
let response = request
.send()
.await
.context("Failed to send download request")?;
if !response.status().is_success() {
anyhow::bail!("HTTP {} from {url}", response.status());
}
let content_length = response.content_length();
let total_size = content_length.unwrap_or(0);
progress(0, total_size);
let sweep_dest = dest.to_path_buf();
let _ = tokio::task::spawn_blocking(move || sweep_abandoned_parts(&sweep_dest)).await;
let mut temp = TempDownload::new(temp_sibling(dest));
let mut file = tokio::fs::File::create(temp.path())
.await
.context("Failed to create temp file")?;
let mut hasher = (!expected_sha256.is_empty()).then_some(Sha256::new());
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Download stream error")?;
let len = chunk.len() as u64;
downloaded += len;
if let Some(h) = &mut hasher {
h.update(&chunk);
}
file.write_all(&chunk)
.await
.context("Failed to write download chunk")?;
progress(downloaded, total_size);
}
file.flush().await?;
file.sync_all().await?;
drop(file);
match size_check {
DownloadSizeCheck::Exact => {
if let Some(expected) = content_length
&& downloaded != expected
{
anyhow::bail!(
"Download size mismatch: expected {expected} bytes, got {downloaded} bytes"
);
}
}
DownloadSizeCheck::Min(min) if downloaded < min => {
anyhow::bail!("Downloaded file too small: {downloaded} bytes");
}
DownloadSizeCheck::Min(_) | DownloadSizeCheck::None => {}
}
if let Some(h) = hasher {
let actual_hash = format!("{:x}", h.finalize());
if actual_hash != expected_sha256 {
return Err(anyhow::Error::new(ChecksumMismatch).context(format!(
"SHA256 mismatch for {}: expected {expected_sha256}, got {actual_hash}",
dest.display()
)));
}
}
tokio::fs::rename(temp.path(), dest)
.await
.with_context(|| format!("Failed to rename temp file to {}", dest.display()))?;
temp.disarm();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_validation_accepts_real_and_local_targets() {
for url in [
"https://example.com",
"HTTP://EXAMPLE.COM",
"https://EXAMPLE.com/path",
"http://localhost:3000",
"http://127.0.0.1",
"http://192.168.1.1",
"http://10.0.0.5",
"http://0x7f000001/",
] {
assert!(validate_url(url).is_ok(), "expected {url:?} to be accepted");
}
}
#[test]
fn url_validation_rejects_unsafe_targets() {
let err = validate_url("").unwrap_err();
assert!(err.to_string().contains("URL cannot be empty"));
for url in ["ftp://example.com", "example.com"] {
let err = validate_url(url).unwrap_err();
assert!(
err.to_string()
.contains("Only http:// and https:// URLs are allowed"),
"unexpected error for {url:?}: {err}"
);
}
for url in ["FILE:///etc/passwd", "file:///etc/passwd"] {
let err = validate_url(url).unwrap_err();
assert!(
err.to_string().contains("not allowed"),
"unexpected error for {url:?}: {err}"
);
}
for url in [
"http://169.254.169.254/",
"http://0.0.0.0/",
"http://0.1.2.3/",
"http://[::1]/",
"http://[::ffff:169.254.169.254]/",
"http://metadata.google.internal/computeMetadata/v1/",
] {
let err = validate_url(url).unwrap_err();
assert!(
err.to_string().contains("SSRF guard"),
"unexpected error for {url:?}: {err}"
);
}
}
#[test]
fn an_address_loses_its_credentials() {
assert_eq!(
redact_address("https://cdn.example.com/clip.mp4?sig=secret&exp=1#t=3"),
"https://cdn.example.com/clip.mp4"
);
assert_eq!(
redact_address("/tmp/workspace/clip.mp4"),
"/tmp/workspace/clip.mp4"
);
}
#[tokio::test]
async fn check_response_returns_http_error_on_non_2xx() {
let http_resp = http::Response::builder()
.status(402)
.body("Insufficient credits: please top up your account".to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "Video generation submission").await;
assert!(result.is_err(), "expected error for 402 status");
assert_eq!(
result
.unwrap_err()
.downcast_ref::<crate::util::error::HttpError>()
.map(|e| e.status),
Some(402),
);
}
#[tokio::test]
async fn check_response_truncates_long_body() {
let long_body = "x".repeat(1000);
let http_resp = http::Response::builder()
.status(400)
.body(long_body.clone())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_err());
let err = result.unwrap_err();
let http_err = err.downcast_ref::<crate::util::error::HttpError>().unwrap();
assert!(
http_err.body.len() <= 502,
"body should be truncated, got {} bytes",
http_err.body.len()
);
assert!(
http_err.body.len() < long_body.len(),
"truncated body ({}) should be shorter than original ({})",
http_err.body.len(),
long_body.len(),
);
assert!(
http_err.body.ends_with('…'),
"truncated body should end with ellipsis"
);
assert_eq!(http_err.status, 400);
}
#[tokio::test]
async fn check_response_returns_ok_on_2xx() {
let http_resp = http::Response::builder()
.status(200)
.body(r#"{"ok": true}"#.to_string())
.unwrap();
let resp = reqwest::Response::from(http_resp);
let result = check_response(resp, "test").await;
assert!(result.is_ok(), "expected success for 200 status");
}
#[test]
fn client_build_installs_ring_provider() {
install_ring_provider();
let _client = build_http_client(Duration::from_secs(5));
let _ = build_download_client(Duration::from_mins(1)).expect("download client builds");
}
}