use super::curl_args;
use chrono;
use serde_json::Value;
use std::sync::OnceLock;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
static CURL_PATH: OnceLock<String> = OnceLock::new();
fn get_curl_path() -> &'static str {
if std::env::var("PACSEA_CURL_PATH").is_ok() {
return Box::leak(Box::new("curl".to_string()));
}
CURL_PATH.get_or_init(|| {
#[cfg(unix)]
{
for path in ["/usr/bin/curl", "/bin/curl", "/usr/local/bin/curl"] {
if std::path::Path::new(path).exists() {
tracing::trace!(curl_path = path, "Using absolute path for curl");
return path.to_string();
}
}
}
#[cfg(target_os = "windows")]
{
let system_paths = [
r"C:\Windows\System32\curl.exe",
r"C:\Program Files\Git\mingw64\bin\curl.exe",
r"C:\Program Files (x86)\Git\mingw64\bin\curl.exe",
r"C:\Program Files\curl\bin\curl.exe",
r"C:\curl\bin\curl.exe",
r"C:\ProgramData\chocolatey\bin\curl.exe",
r"C:\msys64\usr\bin\curl.exe",
r"C:\msys64\mingw64\bin\curl.exe",
r"C:\cygwin64\bin\curl.exe",
r"C:\cygwin\bin\curl.exe",
];
for path in system_paths {
if std::path::Path::new(path).exists() {
tracing::trace!(curl_path = path, "Using absolute path for curl on Windows");
return path.to_string();
}
}
if let Ok(user_profile) = std::env::var("USERPROFILE") {
let user_paths = [
format!(r"{user_profile}\scoop\shims\curl.exe"),
format!(r"{user_profile}\scoop\apps\curl\current\bin\curl.exe"),
format!(r"{user_profile}\scoop\apps\msys2\current\usr\bin\curl.exe"),
format!(r"{user_profile}\scoop\apps\msys2\current\mingw64\bin\curl.exe"),
format!(r"{user_profile}\msys64\usr\bin\curl.exe"),
format!(r"{user_profile}\msys64\mingw64\bin\curl.exe"),
format!(r"{user_profile}\msys2\usr\bin\curl.exe"),
format!(r"{user_profile}\msys2\mingw64\bin\curl.exe"),
format!(r"{user_profile}\.local\bin\curl.exe"),
format!(r"{user_profile}\AppData\Local\Microsoft\WinGet\Packages\curl.exe"),
];
for path in user_paths {
if std::path::Path::new(&path).exists() {
tracing::trace!(
curl_path = %path,
"Using user-specific path for curl on Windows"
);
return path;
}
}
}
}
tracing::trace!("No absolute curl path found, falling back to PATH lookup");
"curl".to_string()
})
}
#[must_use]
pub fn curl_binary_path() -> &'static str {
get_curl_path()
}
#[cfg(target_os = "windows")]
fn redact_url_for_logging(url: &str) -> String {
url.find('?').map_or_else(
|| url.to_string(),
|query_start| format!("{}?[REDACTED]", &url[..query_start]),
)
}
fn extract_http_code_from_output(output: &str) -> Option<u16> {
output
.lines()
.find(|line| line.starts_with("__HTTP_CODE__:"))
.and_then(|line| line.strip_prefix("__HTTP_CODE__:"))
.and_then(|code| code.trim().parse().ok())
}
fn extract_http_code_from_stderr(stderr: &str) -> Option<u16> {
stderr
.find("returned error: ")
.map(|idx| &stderr[idx + "returned error: ".len()..])
.and_then(|s| {
let code_str: String = s.chars().take_while(char::is_ascii_digit).collect();
code_str.parse().ok()
})
}
fn map_curl_error_with_http_code(
code: Option<i32>,
status: std::process::ExitStatus,
http_code: u16,
) -> String {
match http_code {
404 => "HTTP 404: Resource not found (package may not exist in repository)".to_string(),
429 => "HTTP 429: Rate limited by server".to_string(),
500 => "HTTP 500: Internal server error".to_string(),
502 => "HTTP 502: Bad gateway".to_string(),
503 => "HTTP 503: Service temporarily unavailable".to_string(),
504 => "HTTP 504: Gateway timeout".to_string(),
_ if (400..500).contains(&http_code) => {
format!("HTTP {http_code}: Client error")
}
_ if http_code >= 500 => {
format!("HTTP {http_code}: Server error (temporarily unavailable)")
}
_ => map_curl_error(code, status),
}
}
fn map_curl_error(code: Option<i32>, status: std::process::ExitStatus) -> String {
code.map_or_else(
|| {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status.signal().map_or_else(
|| format!("curl process failed: {status:?}"),
|signal| format!("curl process terminated by signal {signal}"),
)
}
#[cfg(not(unix))]
{
format!("curl process failed: {status:?}")
}
},
|code| match code {
22 => "HTTP error from server (code unknown)".to_string(),
6 => "Could not resolve host (DNS/network issue)".to_string(),
7 => "Failed to connect to host (network unreachable)".to_string(),
28 => "Operation timeout".to_string(),
_ => format!("curl failed with exit code {code}"),
},
)
}
pub fn curl_json(url: &str) -> Result<Value> {
let mut args = curl_args(url, &[]);
#[allow(clippy::literal_string_with_formatting_args)]
let write_out_format = "\n__HTTP_CODE__:%{http_code}".to_string();
args.push("-w".to_string());
args.push(write_out_format);
let curl_bin = get_curl_path();
#[cfg(target_os = "windows")]
{
let safe_url = redact_url_for_logging(url);
tracing::debug!(
curl_bin = %curl_bin,
url = %safe_url,
"Executing curl command on Windows"
);
}
let out = std::process::Command::new(curl_bin).args(&args).output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let http_code = extract_http_code_from_output(&stdout)
.or_else(|| extract_http_code_from_stderr(&stderr));
let error_msg = if let Some(code) = http_code {
map_curl_error_with_http_code(out.status.code(), out.status, code)
} else {
map_curl_error(out.status.code(), out.status)
};
#[cfg(target_os = "windows")]
{
let safe_url = redact_url_for_logging(url);
if !stderr.is_empty() {
tracing::warn!(stderr = %stderr, url = %safe_url, "curl stderr output on Windows");
}
if !stdout.is_empty() {
tracing::debug!(stdout = %stdout, url = %safe_url, "curl stdout on Windows (non-success)");
}
}
return Err(error_msg.into());
}
let raw_body = String::from_utf8(out.stdout)?;
let body = raw_body
.lines()
.filter(|line| !line.starts_with("__HTTP_CODE__:"))
.collect::<Vec<_>>()
.join("\n");
#[cfg(target_os = "windows")]
{
let safe_url = redact_url_for_logging(url);
if body.len() < 500 {
tracing::debug!(
url = %safe_url,
response_length = body.len(),
"curl response received on Windows"
);
} else {
tracing::debug!(
url = %safe_url,
response_length = body.len(),
"curl response received on Windows (truncated)"
);
}
}
let v: Value = serde_json::from_str(&body)?;
Ok(v)
}
pub fn curl_text(url: &str) -> Result<String> {
curl_text_with_args(url, &[])
}
fn parse_retry_after(retry_after: &str) -> Option<u64> {
let trimmed = retry_after.trim();
if let Ok(seconds) = trimmed.parse::<u64>() {
return Some(seconds);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let retry_time = dt.with_timezone(&chrono::Utc);
if retry_time > now {
let duration = retry_time - now;
let seconds = duration.num_seconds().max(0);
#[allow(clippy::cast_sign_loss)]
return Some(seconds as u64);
}
return Some(0);
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
let now = chrono::Utc::now();
let retry_time = dt.with_timezone(&chrono::Utc);
if retry_time > now {
let duration = retry_time - now;
let seconds = duration.num_seconds().max(0);
#[allow(clippy::cast_sign_loss)]
return Some(seconds as u64);
}
return Some(0);
}
None
}
fn extract_header_value(headers_text: &str, header_name: &str) -> Option<String> {
let header_lower = header_name.to_lowercase();
for line in headers_text.lines() {
let line_lower = line.trim_start().to_lowercase();
if line_lower.starts_with(&format!("{header_lower}:"))
&& let Some(colon_pos) = line.find(':')
{
let value = line[colon_pos + 1..].trim().to_string();
return Some(value);
}
}
None
}
fn extract_retry_after(headers_text: &str) -> Option<u64> {
extract_header_value(headers_text, "Retry-After")
.as_deref()
.and_then(parse_retry_after)
}
#[derive(Debug, Clone)]
pub struct CurlResponse {
pub body: String,
pub status_code: Option<u16>,
pub retry_after_seconds: Option<u64>,
pub etag: Option<String>,
pub last_modified: Option<String>,
}
pub fn curl_text_with_args_headers(url: &str, extra_args: &[&str]) -> Result<CurlResponse> {
let mut args = curl_args(url, extra_args);
args.push("-i".to_string());
args.push("-w".to_string());
args.push("\n%{http_code}\n".to_string());
let curl_bin = get_curl_path();
let out = std::process::Command::new(curl_bin)
.args(&args)
.output()
.map_err(|e| {
format!("curl command failed to execute: {e} (is curl installed and in PATH?)")
})?;
let stdout = String::from_utf8(out.stdout)?;
let status_code = stdout
.lines()
.last()
.and_then(|line| line.trim().parse::<u16>().ok());
let lines: Vec<&str> = stdout.lines().collect();
let mut header_end = 0;
let mut found_empty_line = false;
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() && i > 0 {
header_end = i;
found_empty_line = true;
break;
}
}
let (headers_text, body_lines) = if found_empty_line {
let headers: Vec<&str> = lines[..header_end].to_vec();
let body_end = lines.len().saturating_sub(1); let body: Vec<&str> = if header_end + 1 < body_end {
lines[header_end + 1..body_end].to_vec()
} else {
vec![]
};
(headers.join("\n"), body.join("\n"))
} else {
let body_end = lines.len().saturating_sub(1);
let body: Vec<&str> = if body_end > 0 {
lines[..body_end].to_vec()
} else {
vec![]
};
(String::new(), body.join("\n"))
};
let retry_after_seconds = (!headers_text.is_empty())
.then(|| extract_retry_after(&headers_text))
.flatten();
let etag = (!headers_text.is_empty())
.then(|| extract_header_value(&headers_text, "ETag"))
.flatten();
let last_modified = (!headers_text.is_empty())
.then(|| extract_header_value(&headers_text, "Last-Modified"))
.flatten();
Ok(CurlResponse {
body: body_lines,
status_code,
retry_after_seconds,
etag,
last_modified,
})
}
pub fn curl_text_with_args(url: &str, extra_args: &[&str]) -> Result<String> {
let mut args = curl_args(url, extra_args);
args.push("-i".to_string());
args.push("-w".to_string());
args.push("\n%{http_code}\n".to_string());
let curl_bin = get_curl_path();
let out = std::process::Command::new(curl_bin)
.args(&args)
.output()
.map_err(|e| {
format!("curl command failed to execute: {e} (is curl installed and in PATH?)")
})?;
let stdout = String::from_utf8(out.stdout)?;
let lines: Vec<&str> = stdout.lines().collect();
let (status_code, body_end) = lines.last().map_or((None, lines.len()), |last_line| {
let trimmed = last_line.trim();
if trimmed.len() == 3 && trimmed.chars().all(|c| c.is_ascii_digit()) {
(
trimmed.parse::<u16>().ok(),
lines.len().saturating_sub(1), )
} else {
(None, lines.len())
}
});
let mut header_end = 0;
let mut found_empty_line = false;
for (i, line) in lines.iter().enumerate() {
if line.trim().is_empty() && i > 0 {
header_end = i;
found_empty_line = true;
break;
}
}
let (headers_text, body_lines) = if found_empty_line {
let headers: Vec<&str> = lines[..header_end].to_vec();
let has_actual_headers = headers.iter().any(|h| !h.trim().is_empty());
if has_actual_headers {
let body: Vec<&str> = if header_end + 1 < body_end {
lines[header_end + 1..body_end].to_vec()
} else {
vec![]
};
(headers.join("\n"), body.join("\n"))
} else {
let body: Vec<&str> = if body_end > 0 {
lines[..body_end]
.iter()
.filter(|line| !line.trim().is_empty())
.copied()
.collect()
} else {
vec![]
};
(String::new(), body.join("\n"))
}
} else {
let body: Vec<&str> = if body_end > 0 {
lines[..body_end].to_vec()
} else {
vec![]
};
(String::new(), body.join("\n"))
};
let retry_after_seconds = if headers_text.is_empty() {
None
} else {
extract_retry_after(&headers_text)
};
if let Some(code) = status_code
&& code >= 400
{
if code == 429 {
let mut error_msg = "HTTP 429 Too Many Requests - rate limited by server".to_string();
if let Some(retry_after) = retry_after_seconds {
error_msg.push_str(" (Retry-After: ");
error_msg.push_str(&retry_after.to_string());
error_msg.push_str("s)");
}
return Err(error_msg.into());
}
if code == 503 {
let mut error_msg = "HTTP 503 Service Unavailable".to_string();
if let Some(retry_after) = retry_after_seconds {
error_msg.push_str(" (Retry-After: ");
error_msg.push_str(&retry_after.to_string());
error_msg.push_str("s)");
}
return Err(error_msg.into());
}
}
if !out.status.success() {
let error_msg = map_curl_error(out.status.code(), out.status);
return Err(error_msg.into());
}
Ok(body_lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_curl_path_returns_valid_path() {
let path = get_curl_path();
assert!(
path == "curl"
|| path.starts_with('/')
|| path.starts_with("C:\\")
|| path.starts_with(r"C:\"),
"Expected valid curl path, got: {path}"
);
}
#[test]
fn test_get_curl_path_is_cached() {
let path1 = get_curl_path();
let path2 = get_curl_path();
assert_eq!(path1, path2, "Curl path should be cached and consistent");
}
#[test]
#[cfg(unix)]
fn test_get_curl_path_prefers_absolute_on_unix() {
let path = get_curl_path();
if std::path::Path::new("/usr/bin/curl").exists()
|| std::path::Path::new("/bin/curl").exists()
|| std::path::Path::new("/usr/local/bin/curl").exists()
{
assert!(
path.starts_with('/'),
"Expected absolute path on Unix when curl is in standard location, got: {path}"
);
}
}
#[test]
fn test_redact_url_for_logging_with_query_params() {
fn redact_url(url: &str) -> String {
url.find('?').map_or_else(
|| url.to_string(),
|query_start| format!("{}?[REDACTED]", &url[..query_start]),
)
}
let url_with_params = "https://api.example.com/search?apikey=secret123&query=test";
let redacted = redact_url(url_with_params);
assert_eq!(redacted, "https://api.example.com/search?[REDACTED]");
assert!(!redacted.contains("secret123"));
assert!(!redacted.contains("apikey"));
}
#[test]
fn test_redact_url_for_logging_without_query_params() {
fn redact_url(url: &str) -> String {
url.find('?').map_or_else(
|| url.to_string(),
|query_start| format!("{}?[REDACTED]", &url[..query_start]),
)
}
let url_no_params = "https://archlinux.org/mirrors/status/json/";
let redacted = redact_url(url_no_params);
assert_eq!(redacted, url_no_params);
}
#[test]
fn test_redact_url_for_logging_empty_query() {
fn redact_url(url: &str) -> String {
url.find('?').map_or_else(
|| url.to_string(),
|query_start| format!("{}?[REDACTED]", &url[..query_start]),
)
}
let url_empty_query = "https://example.com/path?";
let redacted = redact_url(url_empty_query);
assert_eq!(redacted, "https://example.com/path?[REDACTED]");
}
#[test]
#[cfg(unix)]
fn test_map_curl_error_common_codes() {
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
let status = ExitStatus::from_raw(22 << 8);
let msg = map_curl_error(Some(22), status);
assert!(msg.contains("HTTP error"));
let status = ExitStatus::from_raw(6 << 8);
let msg = map_curl_error(Some(6), status);
assert!(msg.contains("resolve host"));
let status = ExitStatus::from_raw(7 << 8);
let msg = map_curl_error(Some(7), status);
assert!(msg.contains("connect"));
let status = ExitStatus::from_raw(28 << 8);
let msg = map_curl_error(Some(28), status);
assert!(msg.contains("timeout"));
}
}