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 {
install_ring_provider();
reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client (rustls TLS initialization failure)")
}
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)
}
#[cfg(unix)]
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::<i32>().ok());
if let Some(pid) = pid
&& pid != std::process::id().cast_signed()
&& !process_exists(pid)
{
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(unix)]
fn process_exists(pid: i32) -> bool {
if unsafe { libc::kill(pid, 0) } == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
fn sweep_abandoned_parts(_dest: &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::*;
#[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");
}
}