pacsea/util/
curl.rs

1//! Curl-based HTTP utilities for fetching JSON and text content.
2//!
3//! This module provides functions for executing curl commands and handling
4//! common error cases with user-friendly error messages.
5//!
6//! # Security
7//! - Uses absolute paths for curl binary when available (defense-in-depth against PATH hijacking)
8//! - Redacts URL query parameters in debug logs to prevent potential secret leakage
9
10use super::curl_args;
11use chrono;
12use serde_json::Value;
13use std::sync::OnceLock;
14
15/// What: Result type alias for curl utility errors.
16///
17/// Inputs: None (type alias).
18///
19/// Output: Result type with boxed error trait object.
20///
21/// Details: Standard error type for curl operations.
22type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
23
24/// Cached curl binary path for performance (computed once at first use).
25static CURL_PATH: OnceLock<String> = OnceLock::new();
26
27/// What: Find the curl binary path, preferring absolute paths for security.
28///
29/// Inputs: None
30///
31/// Output:
32/// - Path to curl binary (absolute path if found, otherwise "curl" for PATH lookup)
33///
34/// Details:
35/// - If `PACSEA_CURL_PATH` env var is set, returns "curl" to use PATH lookup (for testing)
36/// - On Unix: Checks `/usr/bin/curl`, `/bin/curl`, `/usr/local/bin/curl`
37/// - On Windows: Checks system paths (System32, Git, MSYS2, Cygwin, Chocolatey)
38///   and user paths (Scoop, `WinGet`, local installs)
39/// - Falls back to PATH lookup if no absolute path is found
40/// - Result is cached for performance using `OnceLock` (except when env override is set)
41/// - Defense-in-depth measure against PATH hijacking attacks
42fn get_curl_path() -> &'static str {
43    // Check for test override BEFORE using cache - allows tests to inject fake curl
44    // This check is outside OnceLock so it's evaluated on every call
45    if std::env::var("PACSEA_CURL_PATH").is_ok() {
46        // Leak a static string for the "curl" fallback in test mode
47        // This is intentional: tests need a consistent &'static str return type
48        return Box::leak(Box::new("curl".to_string()));
49    }
50
51    CURL_PATH.get_or_init(|| {
52        // Check common absolute paths first (defense-in-depth against PATH hijacking)
53        #[cfg(unix)]
54        {
55            for path in ["/usr/bin/curl", "/bin/curl", "/usr/local/bin/curl"] {
56                if std::path::Path::new(path).exists() {
57                    tracing::trace!(curl_path = path, "Using absolute path for curl");
58                    return path.to_string();
59                }
60            }
61        }
62
63        #[cfg(target_os = "windows")]
64        {
65            // On Windows, check common system installation paths first
66            let system_paths = [
67                r"C:\Windows\System32\curl.exe",
68                r"C:\Program Files\Git\mingw64\bin\curl.exe",
69                r"C:\Program Files (x86)\Git\mingw64\bin\curl.exe",
70                r"C:\Program Files\curl\bin\curl.exe",
71                r"C:\curl\bin\curl.exe",
72                r"C:\ProgramData\chocolatey\bin\curl.exe",
73                r"C:\msys64\usr\bin\curl.exe",
74                r"C:\msys64\mingw64\bin\curl.exe",
75                r"C:\cygwin64\bin\curl.exe",
76                r"C:\cygwin\bin\curl.exe",
77            ];
78
79            for path in system_paths {
80                if std::path::Path::new(path).exists() {
81                    tracing::trace!(curl_path = path, "Using absolute path for curl on Windows");
82                    return path.to_string();
83                }
84            }
85
86            // Check user-specific paths (Scoop, MSYS2, local installs)
87            if let Ok(user_profile) = std::env::var("USERPROFILE") {
88                let user_paths = [
89                    // Scoop
90                    format!(r"{user_profile}\scoop\shims\curl.exe"),
91                    format!(r"{user_profile}\scoop\apps\curl\current\bin\curl.exe"),
92                    format!(r"{user_profile}\scoop\apps\msys2\current\usr\bin\curl.exe"),
93                    format!(r"{user_profile}\scoop\apps\msys2\current\mingw64\bin\curl.exe"),
94                    // MSYS2 user installs
95                    format!(r"{user_profile}\msys64\usr\bin\curl.exe"),
96                    format!(r"{user_profile}\msys64\mingw64\bin\curl.exe"),
97                    format!(r"{user_profile}\msys2\usr\bin\curl.exe"),
98                    format!(r"{user_profile}\msys2\mingw64\bin\curl.exe"),
99                    // Other user paths
100                    format!(r"{user_profile}\.local\bin\curl.exe"),
101                    format!(r"{user_profile}\AppData\Local\Microsoft\WinGet\Packages\curl.exe"),
102                ];
103
104                for path in user_paths {
105                    if std::path::Path::new(&path).exists() {
106                        tracing::trace!(
107                            curl_path = %path,
108                            "Using user-specific path for curl on Windows"
109                        );
110                        return path;
111                    }
112                }
113            }
114        }
115
116        // Fallback to PATH lookup
117        tracing::trace!("No absolute curl path found, falling back to PATH lookup");
118        "curl".to_string()
119    })
120}
121
122/// What: Redact query parameters from a URL for safe logging.
123///
124/// Inputs:
125/// - `url`: The full URL that may contain query parameters
126///
127/// Output:
128/// - URL with query parameters replaced by `?[REDACTED]` if present
129///
130/// Details:
131/// - Prevents potential secret leakage in logs (API keys, tokens in query strings)
132/// - Returns original URL if no query parameters are present
133#[cfg(target_os = "windows")]
134fn redact_url_for_logging(url: &str) -> String {
135    url.find('?').map_or_else(
136        || url.to_string(),
137        |query_start| format!("{}?[REDACTED]", &url[..query_start]),
138    )
139}
140
141/// What: Extract HTTP code from curl's `-w` output format.
142///
143/// Inputs:
144/// - `output`: The stdout output from curl that may contain `__HTTP_CODE__:XXX`
145///
146/// Output:
147/// - Some(u16) if an HTTP code was found, None otherwise
148///
149/// Details:
150/// - Looks for the `__HTTP_CODE__:` marker we add via `-w` flag
151fn extract_http_code_from_output(output: &str) -> Option<u16> {
152    output
153        .lines()
154        .find(|line| line.starts_with("__HTTP_CODE__:"))
155        .and_then(|line| line.strip_prefix("__HTTP_CODE__:"))
156        .and_then(|code| code.trim().parse().ok())
157}
158
159/// What: Extract HTTP code from curl's stderr error message.
160///
161/// Inputs:
162/// - `stderr`: The stderr output from curl
163///
164/// Output:
165/// - Some(u16) if an HTTP code was found in the error message, None otherwise
166///
167/// Details:
168/// - Parses curl's error format: "The requested URL returned error: XXX"
169fn extract_http_code_from_stderr(stderr: &str) -> Option<u16> {
170    // curl stderr format: "curl: (22) The requested URL returned error: 404"
171    stderr
172        .find("returned error: ")
173        .map(|idx| &stderr[idx + "returned error: ".len()..])
174        .and_then(|s| {
175            // Extract just the numeric part
176            let code_str: String = s.chars().take_while(char::is_ascii_digit).collect();
177            code_str.parse().ok()
178        })
179}
180
181/// What: Maps curl exit code to a human-readable error message with HTTP code info.
182///
183/// Inputs:
184/// - `code`: Exit code from curl process.
185/// - `status`: The full process exit status for signal handling.
186/// - `http_code`: The actual HTTP status code from the server.
187///
188/// Output:
189/// - Human-readable error string describing the network issue with specific HTTP code.
190///
191/// Details:
192/// - Provides more specific error messages when HTTP code is known
193/// - 404 is "Resource not found", 429 is "Rate limited", etc.
194fn map_curl_error_with_http_code(
195    code: Option<i32>,
196    status: std::process::ExitStatus,
197    http_code: u16,
198) -> String {
199    // If we have the actual HTTP code, provide a more specific message
200    match http_code {
201        404 => "HTTP 404: Resource not found (package may not exist in repository)".to_string(),
202        429 => "HTTP 429: Rate limited by server".to_string(),
203        500 => "HTTP 500: Internal server error".to_string(),
204        502 => "HTTP 502: Bad gateway".to_string(),
205        503 => "HTTP 503: Service temporarily unavailable".to_string(),
206        504 => "HTTP 504: Gateway timeout".to_string(),
207        _ if (400..500).contains(&http_code) => {
208            format!("HTTP {http_code}: Client error")
209        }
210        _ if http_code >= 500 => {
211            format!("HTTP {http_code}: Server error (temporarily unavailable)")
212        }
213        _ => map_curl_error(code, status),
214    }
215}
216
217/// What: Map curl exit codes to user-friendly error messages.
218///
219/// Inputs:
220/// - `code`: Optional exit code from curl command
221/// - `status`: Exit status for fallback error message
222///
223/// Output:
224/// - User-friendly error message string
225///
226/// Details:
227/// - Maps common curl exit codes (22, 6, 7, 28) to descriptive messages
228/// - Falls back to generic error message if code is unknown
229fn map_curl_error(code: Option<i32>, status: std::process::ExitStatus) -> String {
230    code.map_or_else(
231        || {
232            // Process was terminated by a signal or other reason
233            #[cfg(unix)]
234            {
235                use std::os::unix::process::ExitStatusExt;
236                status.signal().map_or_else(
237                    || format!("curl process failed: {status:?}"),
238                    |signal| format!("curl process terminated by signal {signal}"),
239                )
240            }
241            #[cfg(not(unix))]
242            {
243                format!("curl process failed: {status:?}")
244            }
245        },
246        |code| match code {
247            22 => "HTTP error from server (code unknown)".to_string(),
248            6 => "Could not resolve host (DNS/network issue)".to_string(),
249            7 => "Failed to connect to host (network unreachable)".to_string(),
250            28 => "Operation timeout".to_string(),
251            _ => format!("curl failed with exit code {code}"),
252        },
253    )
254}
255
256/// What: Fetch JSON from a URL using curl and parse into `serde_json::Value`.
257///
258/// Inputs:
259/// - `url`: HTTP(S) URL to request
260///
261/// Output:
262/// - `Ok(Value)` on success; `Err` if curl fails or the response is not valid JSON
263///
264/// # Errors
265/// - Returns `Err` when curl command execution fails (I/O error or curl not found)
266/// - Returns `Err` when curl exits with non-zero status (network errors, HTTP errors, timeouts)
267/// - Returns `Err` when response body cannot be decoded as UTF-8
268/// - Returns `Err` when response body cannot be parsed as JSON
269///
270/// Details:
271/// - Executes curl with appropriate flags and parses the UTF-8 body with `serde_json`.
272/// - On Windows, uses `-k` flag to skip SSL certificate verification.
273/// - Provides user-friendly error messages for common curl failure cases.
274/// - For HTTP errors, includes the actual status code in the error message when available.
275pub fn curl_json(url: &str) -> Result<Value> {
276    let mut args = curl_args(url, &[]);
277    // Add write-out format to capture HTTP status code on failure
278    // The %{http_code} is curl's write-out format, not a Rust format string
279    #[allow(clippy::literal_string_with_formatting_args)]
280    let write_out_format = "\n__HTTP_CODE__:%{http_code}".to_string();
281    args.push("-w".to_string());
282    args.push(write_out_format);
283    let curl_bin = get_curl_path();
284    #[cfg(target_os = "windows")]
285    {
286        // On Windows, log curl command for debugging (URL redacted for security)
287        let safe_url = redact_url_for_logging(url);
288        tracing::debug!(
289            curl_bin = %curl_bin,
290            url = %safe_url,
291            "Executing curl command on Windows"
292        );
293    }
294    let out = std::process::Command::new(curl_bin).args(&args).output()?;
295    if !out.status.success() {
296        // Try to extract HTTP status code from stderr or stdout
297        let stderr = String::from_utf8_lossy(&out.stderr);
298        let stdout = String::from_utf8_lossy(&out.stdout);
299
300        // Look for HTTP code in the output
301        let http_code = extract_http_code_from_output(&stdout)
302            .or_else(|| extract_http_code_from_stderr(&stderr));
303
304        let error_msg = if let Some(code) = http_code {
305            map_curl_error_with_http_code(out.status.code(), out.status, code)
306        } else {
307            map_curl_error(out.status.code(), out.status)
308        };
309
310        #[cfg(target_os = "windows")]
311        {
312            let safe_url = redact_url_for_logging(url);
313            // On Windows, also log stderr for debugging
314            if !stderr.is_empty() {
315                tracing::warn!(stderr = %stderr, url = %safe_url, "curl stderr output on Windows");
316            }
317            // Also log stdout in case there's useful info there
318            if !stdout.is_empty() {
319                tracing::debug!(stdout = %stdout, url = %safe_url, "curl stdout on Windows (non-success)");
320            }
321        }
322        return Err(error_msg.into());
323    }
324    let raw_body = String::from_utf8(out.stdout)?;
325    // Strip the __HTTP_CODE__:XXX suffix we added via -w flag
326    let body = raw_body
327        .lines()
328        .filter(|line| !line.starts_with("__HTTP_CODE__:"))
329        .collect::<Vec<_>>()
330        .join("\n");
331    #[cfg(target_os = "windows")]
332    {
333        // On Windows, log response details for debugging API issues (URL redacted)
334        let safe_url = redact_url_for_logging(url);
335        if body.len() < 500 {
336            tracing::debug!(
337                url = %safe_url,
338                response_length = body.len(),
339                "curl response received on Windows"
340            );
341        } else {
342            tracing::debug!(
343                url = %safe_url,
344                response_length = body.len(),
345                "curl response received on Windows (truncated)"
346            );
347        }
348    }
349    let v: Value = serde_json::from_str(&body)?;
350    Ok(v)
351}
352
353/// What: Fetch plain text from a URL using curl.
354///
355/// Inputs:
356/// - `url`: URL to request
357///
358/// Output:
359/// - `Ok(String)` with response body; `Err` if curl or UTF-8 decoding fails
360///
361/// # Errors
362/// - Returns `Err` when curl command execution fails (I/O error or curl not found)
363/// - Returns `Err` when curl exits with non-zero status (network errors, HTTP errors, timeouts)
364/// - Returns `Err` when response body cannot be decoded as UTF-8
365///
366/// Details:
367/// - Executes curl with appropriate flags and returns the raw body as a `String`.
368/// - On Windows, uses `-k` flag to skip SSL certificate verification.
369/// - Provides user-friendly error messages for common curl failure cases.
370pub fn curl_text(url: &str) -> Result<String> {
371    curl_text_with_args(url, &[])
372}
373
374/// What: Parse Retry-After header value into seconds.
375///
376/// Inputs:
377/// - `retry_after`: Retry-After header value (can be seconds as number or HTTP-date)
378///
379/// Output:
380/// - `Some(seconds)` if parsing succeeds, `None` otherwise
381///
382/// Details:
383/// - Supports both numeric format (seconds) and HTTP-date format (RFC 7231).
384/// - For HTTP-date, calculates seconds until that date.
385fn parse_retry_after(retry_after: &str) -> Option<u64> {
386    let trimmed = retry_after.trim();
387    // Try parsing as number (seconds)
388    if let Ok(seconds) = trimmed.parse::<u64>() {
389        return Some(seconds);
390    }
391    // Try parsing as HTTP-date (RFC 7231)
392    // Common formats: "Wed, 21 Oct 2015 07:28:00 GMT", "Wed, 21 Oct 2015 07:28:00 +0000"
393    if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
394        let now = chrono::Utc::now();
395        let retry_time = dt.with_timezone(&chrono::Utc);
396        if retry_time > now {
397            let duration = retry_time - now;
398            let seconds = duration.num_seconds().max(0);
399            // Safe: seconds is non-negative, and u64::MAX is much larger than any reasonable retry time
400            #[allow(clippy::cast_sign_loss)]
401            return Some(seconds as u64);
402        }
403        return Some(0);
404    }
405    // Try RFC 3339 format
406    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
407        let now = chrono::Utc::now();
408        let retry_time = dt.with_timezone(&chrono::Utc);
409        if retry_time > now {
410            let duration = retry_time - now;
411            let seconds = duration.num_seconds().max(0);
412            // Safe: seconds is non-negative, and u64::MAX is much larger than any reasonable retry time
413            #[allow(clippy::cast_sign_loss)]
414            return Some(seconds as u64);
415        }
416        return Some(0);
417    }
418    None
419}
420
421/// What: Extract header value from HTTP response headers (case-insensitive).
422///
423/// Inputs:
424/// - `headers_text`: Raw HTTP headers text (from curl -i output)
425/// - `header_name`: Name of the header to extract (case-insensitive)
426///
427/// Output:
428/// - `Some(value)` if header found, `None` otherwise
429///
430/// Details:
431/// - Searches for header name (case-insensitive).
432/// - Returns trimmed value after the colon.
433fn extract_header_value(headers_text: &str, header_name: &str) -> Option<String> {
434    let header_lower = header_name.to_lowercase();
435    for line in headers_text.lines() {
436        let line_lower = line.trim_start().to_lowercase();
437        if line_lower.starts_with(&format!("{header_lower}:"))
438            && let Some(colon_pos) = line.find(':')
439        {
440            let value = line[colon_pos + 1..].trim().to_string();
441            return Some(value);
442        }
443    }
444    None
445}
446
447/// What: Extract Retry-After header value from HTTP response headers.
448///
449/// Inputs:
450/// - `headers_text`: Raw HTTP headers text (from curl -i output)
451///
452/// Output:
453/// - `Some(seconds)` if Retry-After header found and parsed, `None` otherwise
454///
455/// Details:
456/// - Searches for "Retry-After:" header (case-insensitive).
457/// - Parses the value using `parse_retry_after()`.
458fn extract_retry_after(headers_text: &str) -> Option<u64> {
459    extract_header_value(headers_text, "Retry-After")
460        .as_deref()
461        .and_then(parse_retry_after)
462}
463
464/// Response metadata including headers for parsing `Retry-After`, `ETag`, and `Last-Modified`.
465#[derive(Debug, Clone)]
466pub struct CurlResponse {
467    /// Response body.
468    pub body: String,
469    /// HTTP status code.
470    pub status_code: Option<u16>,
471    /// Retry-After header value in seconds, if present.
472    pub retry_after_seconds: Option<u64>,
473    /// `ETag` header value, if present.
474    pub etag: Option<String>,
475    /// Last-Modified header value, if present.
476    pub last_modified: Option<String>,
477}
478
479/// What: Fetch plain text from a URL using curl with custom arguments, including headers.
480///
481/// Inputs:
482/// - `url`: URL to request
483/// - `extra_args`: Additional curl arguments (e.g., `["--max-time", "10"]`)
484///
485/// Output:
486/// - `Ok(CurlResponse)` with response body, status code, and parsed headers; `Err` if curl or UTF-8 decoding fails
487///
488/// # Errors
489/// - Returns `Err` when curl command execution fails (I/O error or curl not found)
490/// - Returns `Err` when curl exits with non-zero status (network errors, HTTP errors, timeouts)
491/// - Returns `Err` when response body cannot be decoded as UTF-8
492///
493/// Details:
494/// - Executes curl with `-i` flag to include headers in output.
495/// - Uses `-w "\n%{http_code}\n"` to get HTTP status code at the end.
496/// - Parses Retry-After header from response headers.
497/// - Separates headers from body in the response.
498pub fn curl_text_with_args_headers(url: &str, extra_args: &[&str]) -> Result<CurlResponse> {
499    let mut args = curl_args(url, extra_args);
500    // Include headers in output (-i flag)
501    args.push("-i".to_string());
502    // Append write-out format to get HTTP status code at the end
503    args.push("-w".to_string());
504    args.push("\n%{http_code}\n".to_string());
505    let curl_bin = get_curl_path();
506    let out = std::process::Command::new(curl_bin)
507        .args(&args)
508        .output()
509        .map_err(|e| {
510            format!("curl command failed to execute: {e} (is curl installed and in PATH?)")
511        })?;
512
513    let stdout = String::from_utf8(out.stdout)?;
514
515    // Parse status code from the end of output (last line should be the status code)
516    let status_code = stdout
517        .lines()
518        .last()
519        .and_then(|line| line.trim().parse::<u16>().ok());
520
521    // Find the boundary between headers and body (empty line)
522    let lines: Vec<&str> = stdout.lines().collect();
523    let mut header_end = 0;
524    let mut found_empty_line = false;
525    for (i, line) in lines.iter().enumerate() {
526        if line.trim().is_empty() && i > 0 {
527            // Found empty line separating headers from body
528            header_end = i;
529            found_empty_line = true;
530            break;
531        }
532    }
533
534    // Extract headers and body
535    let (headers_text, body_lines) = if found_empty_line {
536        let headers: Vec<&str> = lines[..header_end].to_vec();
537        // Skip the empty line and status code line at the end
538        let body_end = lines.len().saturating_sub(1); // Exclude status code line
539        let body: Vec<&str> = if header_end + 1 < body_end {
540            lines[header_end + 1..body_end].to_vec()
541        } else {
542            vec![]
543        };
544        (headers.join("\n"), body.join("\n"))
545    } else {
546        // No headers found, treat entire output as body (minus status code)
547        let body_end = lines.len().saturating_sub(1);
548        let body: Vec<&str> = if body_end > 0 {
549            lines[..body_end].to_vec()
550        } else {
551            vec![]
552        };
553        (String::new(), body.join("\n"))
554    };
555
556    // Parse headers
557    let retry_after_seconds = (!headers_text.is_empty())
558        .then(|| extract_retry_after(&headers_text))
559        .flatten();
560    let etag = (!headers_text.is_empty())
561        .then(|| extract_header_value(&headers_text, "ETag"))
562        .flatten();
563    let last_modified = (!headers_text.is_empty())
564        .then(|| extract_header_value(&headers_text, "Last-Modified"))
565        .flatten();
566
567    Ok(CurlResponse {
568        body: body_lines,
569        status_code,
570        retry_after_seconds,
571        etag,
572        last_modified,
573    })
574}
575
576/// What: Fetch plain text from a URL using curl with custom arguments.
577///
578/// Inputs:
579/// - `url`: URL to request
580/// - `extra_args`: Additional curl arguments (e.g., `["--max-time", "10"]`)
581///
582/// Output:
583/// - `Ok(String)` with response body; `Err` if curl or UTF-8 decoding fails
584///
585/// # Errors
586/// - Returns `Err` when curl command execution fails (I/O error or curl not found)
587/// - Returns `Err` when curl exits with non-zero status (network errors, HTTP errors, timeouts)
588/// - Returns `Err` when response body cannot be decoded as UTF-8
589/// - Returns `Err` with message containing "429" when HTTP 429 (Too Many Requests) is received
590///
591/// Details:
592/// - Executes curl with appropriate flags plus extra arguments.
593/// - On Windows, uses `-k` flag to skip SSL certificate verification.
594/// - Uses `-i` flag to include headers for Retry-After parsing.
595/// - Uses `-w "\n%{http_code}\n"` to detect HTTP status codes, especially 429.
596/// - Provides user-friendly error messages for common curl failure cases.
597/// - HTTP 429/503 errors include Retry-After information when available.
598pub fn curl_text_with_args(url: &str, extra_args: &[&str]) -> Result<String> {
599    let mut args = curl_args(url, extra_args);
600    // Include headers in output (-i flag) for Retry-After parsing
601    args.push("-i".to_string());
602    // Append write-out format to get HTTP status code at the end
603    args.push("-w".to_string());
604    args.push("\n%{http_code}\n".to_string());
605    let curl_bin = get_curl_path();
606    let out = std::process::Command::new(curl_bin)
607        .args(&args)
608        .output()
609        .map_err(|e| {
610            format!("curl command failed to execute: {e} (is curl installed and in PATH?)")
611        })?;
612
613    let stdout = String::from_utf8(out.stdout)?;
614
615    // Parse status code from the end of output (last line should be the status code)
616    // Check if last line is a numeric status code (3 digits)
617    let lines: Vec<&str> = stdout.lines().collect();
618    let (status_code, body_end) = lines.last().map_or((None, lines.len()), |last_line| {
619        let trimmed = last_line.trim();
620        // Check if last line looks like an HTTP status code (3 digits)
621        if trimmed.len() == 3 && trimmed.chars().all(|c| c.is_ascii_digit()) {
622            (
623                trimmed.parse::<u16>().ok(),
624                lines.len().saturating_sub(1), // Exclude status code line
625            )
626        } else {
627            // Last line is not a status code, include it in body
628            (None, lines.len())
629        }
630    });
631
632    // Find the boundary between headers and body (empty line)
633    let mut header_end = 0;
634    let mut found_empty_line = false;
635    for (i, line) in lines.iter().enumerate() {
636        if line.trim().is_empty() && i > 0 {
637            // Found empty line separating headers from body
638            header_end = i;
639            found_empty_line = true;
640            break;
641        }
642    }
643
644    // Extract headers and body
645    let (headers_text, body_lines) = if found_empty_line {
646        let headers: Vec<&str> = lines[..header_end].to_vec();
647        // Check if headers section actually contains non-empty lines
648        // If not, treat as if there are no headers (empty line is just formatting)
649        let has_actual_headers = headers.iter().any(|h| !h.trim().is_empty());
650        if has_actual_headers {
651            // Skip the empty line and status code line at the end
652            let body: Vec<&str> = if header_end + 1 < body_end {
653                lines[header_end + 1..body_end].to_vec()
654            } else {
655                vec![]
656            };
657            (headers.join("\n"), body.join("\n"))
658        } else {
659            // No actual headers, treat entire output as body (up to body_end)
660            let body: Vec<&str> = if body_end > 0 {
661                // Include everything up to body_end, filtering out empty lines
662                lines[..body_end]
663                    .iter()
664                    .filter(|line| !line.trim().is_empty())
665                    .copied()
666                    .collect()
667            } else {
668                vec![]
669            };
670            (String::new(), body.join("\n"))
671        }
672    } else {
673        // No headers found, treat entire output as body (up to body_end)
674        let body: Vec<&str> = if body_end > 0 {
675            lines[..body_end].to_vec()
676        } else {
677            vec![]
678        };
679        (String::new(), body.join("\n"))
680    };
681
682    // Parse headers
683    let retry_after_seconds = if headers_text.is_empty() {
684        None
685    } else {
686        extract_retry_after(&headers_text)
687    };
688
689    // Check for HTTP errors
690    if let Some(code) = status_code
691        && code >= 400
692    {
693        // Check if we got HTTP 429 (Too Many Requests)
694        if code == 429 {
695            let mut error_msg = "HTTP 429 Too Many Requests - rate limited by server".to_string();
696            if let Some(retry_after) = retry_after_seconds {
697                error_msg.push_str(" (Retry-After: ");
698                error_msg.push_str(&retry_after.to_string());
699                error_msg.push_str("s)");
700            }
701            return Err(error_msg.into());
702        }
703        if code == 503 {
704            let mut error_msg = "HTTP 503 Service Unavailable".to_string();
705            if let Some(retry_after) = retry_after_seconds {
706                error_msg.push_str(" (Retry-After: ");
707                error_msg.push_str(&retry_after.to_string());
708                error_msg.push_str("s)");
709            }
710            return Err(error_msg.into());
711        }
712    }
713
714    // Check curl exit status for other errors
715    if !out.status.success() {
716        let error_msg = map_curl_error(out.status.code(), out.status);
717        return Err(error_msg.into());
718    }
719
720    Ok(body_lines)
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    #[test]
728    fn test_get_curl_path_returns_valid_path() {
729        let path = get_curl_path();
730        // Should return either an absolute path or "curl"
731        assert!(
732            path == "curl"
733                || path.starts_with('/')
734                || path.starts_with("C:\\")
735                || path.starts_with(r"C:\"),
736            "Expected valid curl path, got: {path}"
737        );
738    }
739
740    #[test]
741    fn test_get_curl_path_is_cached() {
742        // Calling get_curl_path twice should return the same value
743        let path1 = get_curl_path();
744        let path2 = get_curl_path();
745        assert_eq!(path1, path2, "Curl path should be cached and consistent");
746    }
747
748    #[test]
749    #[cfg(unix)]
750    fn test_get_curl_path_prefers_absolute_on_unix() {
751        let path = get_curl_path();
752        // On Unix systems where curl is installed in standard locations,
753        // we should get an absolute path
754        if std::path::Path::new("/usr/bin/curl").exists()
755            || std::path::Path::new("/bin/curl").exists()
756            || std::path::Path::new("/usr/local/bin/curl").exists()
757        {
758            assert!(
759                path.starts_with('/'),
760                "Expected absolute path on Unix when curl is in standard location, got: {path}"
761            );
762        }
763    }
764
765    #[test]
766    fn test_redact_url_for_logging_with_query_params() {
767        // This test is only compiled on Windows, but we can still test the logic
768        fn redact_url(url: &str) -> String {
769            url.find('?').map_or_else(
770                || url.to_string(),
771                |query_start| format!("{}?[REDACTED]", &url[..query_start]),
772            )
773        }
774
775        // URL with query parameters should be redacted
776        let url_with_params = "https://api.example.com/search?apikey=secret123&query=test";
777        let redacted = redact_url(url_with_params);
778        assert_eq!(redacted, "https://api.example.com/search?[REDACTED]");
779        assert!(!redacted.contains("secret123"));
780        assert!(!redacted.contains("apikey"));
781    }
782
783    #[test]
784    fn test_redact_url_for_logging_without_query_params() {
785        fn redact_url(url: &str) -> String {
786            url.find('?').map_or_else(
787                || url.to_string(),
788                |query_start| format!("{}?[REDACTED]", &url[..query_start]),
789            )
790        }
791
792        // URL without query parameters should remain unchanged
793        let url_no_params = "https://archlinux.org/mirrors/status/json/";
794        let redacted = redact_url(url_no_params);
795        assert_eq!(redacted, url_no_params);
796    }
797
798    #[test]
799    fn test_redact_url_for_logging_empty_query() {
800        fn redact_url(url: &str) -> String {
801            url.find('?').map_or_else(
802                || url.to_string(),
803                |query_start| format!("{}?[REDACTED]", &url[..query_start]),
804            )
805        }
806
807        // URL with empty query string should still redact
808        let url_empty_query = "https://example.com/path?";
809        let redacted = redact_url(url_empty_query);
810        assert_eq!(redacted, "https://example.com/path?[REDACTED]");
811    }
812
813    #[test]
814    #[cfg(unix)]
815    fn test_map_curl_error_common_codes() {
816        use std::os::unix::process::ExitStatusExt;
817        use std::process::ExitStatus;
818
819        // Test exit code 22 (HTTP error)
820        let status = ExitStatus::from_raw(22 << 8);
821        let msg = map_curl_error(Some(22), status);
822        assert!(msg.contains("HTTP error"));
823
824        // Test exit code 6 (DNS error)
825        let status = ExitStatus::from_raw(6 << 8);
826        let msg = map_curl_error(Some(6), status);
827        assert!(msg.contains("resolve host"));
828
829        // Test exit code 7 (connection error)
830        let status = ExitStatus::from_raw(7 << 8);
831        let msg = map_curl_error(Some(7), status);
832        assert!(msg.contains("connect"));
833
834        // Test exit code 28 (timeout)
835        let status = ExitStatus::from_raw(28 << 8);
836        let msg = map_curl_error(Some(28), status);
837        assert!(msg.contains("timeout"));
838    }
839}