Skip to main content

shell_download/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod drivers;
4mod process;
5mod sink;
6mod url_parser;
7mod util;
8
9pub use sink::DownloadSink;
10
11use std::fs::OpenOptions;
12use std::io;
13use std::path::Path;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::thread::JoinHandle;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19/// A supported download backend.
20pub enum Downloader {
21    /// Use `curl`.
22    Curl,
23    /// Use `wget`.
24    Wget,
25    /// Use PowerShell (`pwsh`/`powershell`).
26    PowerShell,
27    /// Use Python `urllib`.
28    Python3,
29    /// Minimal HTTP/HTTPS tunnel: TCP for HTTP, OpenSSL (`openssl s_client`) for HTTPS.
30    Tunnel,
31    /// Plain HTTP/1.1 over a TCP socket only (no TLS).
32    Tcp,
33    /// HTTPS via OpenSSL only (`openssl s_client`).
34    OpenSSL,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38/// Controls forwarding of child stdout/stderr.
39pub enum Quiet {
40    /// Never be quiet: always forward child stdout/stderr to the parent process.
41    Never,
42    /// Always be quiet: never forward child stdout/stderr.
43    Always,
44    /// Only be quiet on success: forward output if the command fails.
45    OnSuccess,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49/// Response body content encoding (if known).
50pub enum ContentEncoding {
51    /// Gzip-compressed content.
52    Gzip,
53}
54
55#[derive(Debug, Clone)]
56/// Builder for a single download request.
57pub struct RequestBuilder {
58    pub(crate) url: String,
59    pub(crate) headers: Vec<(String, String)>,
60    pub(crate) preferred: Vec<Downloader>,
61    pub(crate) follow_redirects: bool,
62    pub(crate) quiet: Quiet,
63}
64
65#[derive(Debug, Clone)]
66/// Low-level download result prior to finalizing the output file.
67pub struct DownloadResult {
68    /// HTTP status code (best-effort).
69    pub status_code: u16,
70    /// Response content encoding, if known.
71    pub content_encoding: Option<ContentEncoding>,
72}
73
74impl RequestBuilder {
75    /// Create a new request builder.
76    pub fn new(url: impl Into<String>) -> Self {
77        Self {
78            url: url.into(),
79            headers: Vec::new(),
80            preferred: Vec::new(),
81            follow_redirects: true,
82            quiet: Quiet::Always,
83        }
84    }
85
86    /// Add an HTTP header.
87    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
88        self.headers.push((key.into(), value.into()));
89        self
90    }
91
92    /// Prefer a specific downloader backend.
93    pub fn preferred_downloader(mut self, preferred: Downloader) -> Self {
94        self.preferred.push(preferred);
95        self
96    }
97
98    /// Enable or disable HTTP redirect following.
99    pub fn follow_redirects(mut self, follow_redirects: bool) -> Self {
100        self.follow_redirects = follow_redirects;
101        self
102    }
103
104    /// Control forwarding of child output.
105    pub fn quiet(mut self, quiet: Quiet) -> Self {
106        self.quiet = quiet;
107        self
108    }
109
110    /// Fetch the response body as a String, blocking until the download is
111    /// complete.
112    pub fn fetch_string(self) -> Result<String, ResponseError> {
113        String::from_utf8(self.fetch_bytes()?)
114            .map_err(|e| ResponseError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))
115    }
116
117    /// Fetch the response body into memory, blocking until the download is
118    /// complete.
119    pub fn fetch_bytes(self) -> Result<Vec<u8>, ResponseError> {
120        url_parser::Url::new(&self.url)
121            .map_err(|e| ResponseError::Start(StartError::Url(e.to_string())))?;
122
123        let cancel = Arc::new(AtomicBool::new(false));
124        let buffer = Arc::new(Mutex::new(Vec::new()));
125        let memory_root = DownloadSink::buffer(buffer.clone());
126        let join = self
127            .start_first_backend(Arc::clone(&cancel), memory_root.clone())
128            .map_err(ResponseError::Start)?;
129
130        join.join().map_err(|_| ResponseError::ThreadPanicked)??;
131
132        Ok(std::mem::take(&mut *buffer.lock().unwrap()))
133    }
134
135    /// Start the download in a background thread.
136    pub fn start(self, target_path: impl AsRef<Path>) -> Result<RequestHandle, StartError> {
137        // URL preflight: fail early with a message useful to callers.
138        let url = url_parser::Url::new(&self.url).map_err(|e| StartError::Url(e.to_string()))?;
139
140        let target_file = OpenOptions::new()
141            .create(true)
142            .truncate(true)
143            .write(true)
144            .open(&target_path)?;
145
146        let cancel = Arc::new(AtomicBool::new(false));
147        let sink = DownloadSink::file(target_file);
148        let join = self.start_first_backend(cancel.clone(), sink)?;
149
150        Ok(RequestHandle {
151            cancel,
152            join: Some(join),
153        })
154    }
155
156    /// Run [`candidate_downloaders`] once; `next_sink` prepares the body sink for each attempt.
157    fn start_first_backend(
158        &self,
159        cancel: Arc<AtomicBool>,
160        sink: DownloadSink,
161    ) -> Result<JoinHandle<Result<DownloadResult, ResponseError>>, StartError> {
162        let mut saw_non_not_found: Option<io::Error> = None;
163        let mut saw_any_not_found = false;
164
165        for d in candidate_downloaders(&self.preferred) {
166            match d
167                .driver()
168                .start(self.clone(), sink.clone(), Arc::clone(&cancel))
169            {
170                Ok(join) => return Ok(join),
171                Err(StartError::Url(msg)) => return Err(StartError::Url(msg)),
172                Err(StartError::NoDriverFound) => {
173                    saw_any_not_found = true;
174                    continue;
175                }
176                Err(StartError::IoError(e)) => {
177                    if saw_non_not_found.is_none() {
178                        saw_non_not_found = Some(e);
179                    }
180                    continue;
181                }
182            }
183        }
184
185        if let Some(e) = saw_non_not_found {
186            return Err(StartError::IoError(e));
187        }
188        if saw_any_not_found {
189            return Err(StartError::NoDriverFound);
190        }
191        Err(StartError::NoDriverFound)
192    }
193}
194
195impl Downloader {
196    pub(crate) fn driver(self) -> &'static dyn drivers::Driver {
197        match self {
198            Downloader::Curl => &drivers::curl::CurlDriver,
199            Downloader::Wget => &drivers::wget::WgetDriver,
200            Downloader::PowerShell => &drivers::powershell::PowerShellDriver,
201            Downloader::Python3 => &drivers::python3::Python3Driver,
202            Downloader::Tunnel => &drivers::tunnel::TunnelDriver,
203            Downloader::Tcp => &drivers::tunnel::TcpDriver,
204            Downloader::OpenSSL => &drivers::tunnel::OpenSslDriver,
205        }
206    }
207}
208
209#[derive(Debug)]
210/// Handle for a running download.
211pub struct RequestHandle {
212    cancel: Arc<AtomicBool>,
213    join: Option<JoinHandle<Result<DownloadResult, ResponseError>>>,
214}
215
216impl RequestHandle {
217    /// Request cancellation (best-effort).
218    pub fn cancel(&self) {
219        self.cancel.store(true, Ordering::SeqCst);
220    }
221
222    /// Wait for completion and move the temp download to the target path.
223    pub fn join(mut self) -> Result<Response, ResponseError> {
224        let res = match self.join.take().expect("join called once").join() {
225            Ok(r) => r,
226            Err(_) => Err(ResponseError::ThreadPanicked),
227        }?;
228
229        Ok(Response {
230            status_code: res.status_code,
231        })
232    }
233}
234
235impl Drop for RequestHandle {
236    fn drop(&mut self) {
237        if self.join.is_some() {
238            self.cancel.store(true, Ordering::SeqCst);
239            // `tmp_path` will clean itself up via `Drop`.
240        }
241    }
242}
243
244#[derive(Debug, Clone)]
245/// Final response metadata for a completed download.
246pub struct Response {
247    /// HTTP status code (best-effort).
248    pub status_code: u16,
249}
250
251#[derive(Debug)]
252/// Errors that can occur while starting a download.
253pub enum StartError {
254    /// No usable backend executable was found.
255    NoDriverFound,
256    /// A local I/O error occurred.
257    IoError(io::Error),
258    /// URL validation failed.
259    Url(String),
260}
261
262impl From<io::Error> for StartError {
263    fn from(value: io::Error) -> Self {
264        Self::IoError(value)
265    }
266}
267
268#[derive(Debug)]
269/// Errors that can occur while running a request.
270pub enum ResponseError {
271    /// A local I/O error occurred.
272    Io(io::Error),
273    /// The URL could not be parsed.
274    InvalidUrl,
275    /// The URL scheme is unsupported.
276    UnsupportedScheme,
277    /// The request was cancelled.
278    Cancelled,
279    /// The worker thread panicked.
280    ThreadPanicked,
281    /// The backend command failed.
282    CommandFailed {
283        /// Backend program label.
284        program: &'static str,
285        /// Process exit code, if available.
286        exit_code: Option<i32>,
287        /// Captured stderr (best-effort).
288        stderr: String,
289    },
290    /// The backend returned a non-numeric status code.
291    BadStatusCode(String),
292    /// Gzip decoding failed.
293    GzipFailed {
294        /// Process exit code, if available.
295        exit_code: Option<i32>,
296        /// Captured stderr (best-effort).
297        stderr: String,
298    },
299    /// Download start failed.
300    Start(StartError),
301}
302
303impl From<io::Error> for ResponseError {
304    fn from(value: io::Error) -> Self {
305        Self::Io(value)
306    }
307}
308
309/// Choose downloaders in priority order.
310fn candidate_downloaders(preferred: &[Downloader]) -> Vec<Downloader> {
311    if !preferred.is_empty() {
312        return preferred.to_vec();
313    }
314    vec![
315        Downloader::Curl,
316        Downloader::Wget,
317        Downloader::PowerShell,
318        Downloader::Python3,
319        Downloader::Tunnel,
320    ]
321}