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