Skip to main content

browser_commander/downloads/
mod.rs

1//! Managed, persistent downloads (issue #88).
2//!
3//! The manager owns one lifecycle for every download the browser performs -
4//! automated or started by a person - and guarantees three things a caller
5//! cannot get from an engine event alone:
6//!
7//! - the file survives the page, context and browser that produced it;
8//! - a download is saved once and reported once, even when a global listener
9//!   and an awaited [`DownloadManager::capture`] both see it;
10//! - a file under its final name is complete and has passed validation.
11//!
12//! # The Rust source, and why it is the filesystem
13//!
14//! JavaScript listens to `Browser.downloadWillBegin` and
15//! `Browser.downloadProgress`, which is how it observes a download a *person*
16//! started. This crate's [`CdpTransport`](crate::fingerprint::CdpTransport) is
17//! request/response only - there is no event stream - so Rust points the
18//! browser at a staging directory with `Browser.setDownloadBehavior` and
19//! watches that directory instead. Python does the same for Selenium. The
20//! guarantees above are unchanged; what is lost is the engine's own progress
21//! reporting, and `docs/feature-parity.md` says so rather than leaving it to
22//! be discovered.
23//!
24//! # Example
25//!
26//! ```rust,no_run
27//! use browser_commander::downloads::{DownloadManager, DownloadOptions};
28//!
29//! # async fn example(page: &browser_commander::browser::ChromiumoxidePage)
30//! # -> anyhow::Result<()> {
31//! let manager = DownloadManager::create(DownloadOptions::default())?;
32//! manager.attach(page).await?;
33//! let artifact = manager
34//!     .capture(Default::default(), async { Ok(()) })
35//!     .await?;
36//! println!("saved {}", artifact.path.expect("a completed download has a path").display());
37//! # Ok(())
38//! # }
39//! ```
40
41pub mod attach;
42pub mod destination;
43pub mod manager;
44pub mod naming;
45pub mod options;
46pub mod sources;
47pub mod store;
48pub mod watcher;
49
50use std::path::PathBuf;
51
52pub use attach::{attach_downloads, normalize_download_options, supported_engine, DownloadSetting};
53pub use destination::{
54    prepare_download_directory, resolve_download_directory, DownloadDirectoryPreset,
55    ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE,
56};
57pub use manager::{DownloadManager, DEFAULT_CAPTURE_TIMEOUT};
58pub use naming::{
59    extension_from_content, is_inside_root, renamed_candidate, resolve_inside_root,
60    sanitize_download_name, with_extension,
61};
62pub use options::{CaptureOptions, DownloadArtifact, DownloadEvent, DownloadOptions};
63pub use sources::{
64    classify_failure, prepare_staging_directory, set_download_behavior, DownloadFailure,
65    DownloadSink, DownloadStart, SourceHandle, STAGING_DIRECTORY,
66};
67pub use store::{
68    clean_partials, resolve_final_path, save_download, DownloadCandidate, DownloadConflict,
69    DownloadNamer, DownloadNaming, DownloadSource, DownloadValidator, SaveRequest, SavedDownload,
70};
71pub use watcher::{
72    attach_filesystem_watcher, DirectoryWatcher, StagedDownload, DEFAULT_POLL_INTERVAL,
73    IN_PROGRESS_SUFFIXES,
74};
75
76/// Everything that can stop a download from reaching the caller's directory.
77///
78/// Every variant names the file or directory it is about: a download that
79/// failed is reported by its own name, not by a generic I/O message that
80/// leaves the caller guessing which of several downloads is missing.
81#[derive(Debug, thiserror::Error)]
82pub enum DownloadError {
83    /// A name would have placed the file outside the managed directory.
84    #[error("refusing to write \"{name}\" outside the download directory {}", root.display())]
85    OutsideRoot {
86        /// The name that was refused.
87        name: String,
88        /// The managed download directory.
89        root: PathBuf,
90    },
91
92    /// `downloads.directory` was empty or relative.
93    #[error("downloads.directory must be absolute, received \"{directory}\"")]
94    RelativeDirectory {
95        /// What the caller passed.
96        directory: String,
97    },
98
99    /// The download directory could not be created.
100    #[error("download directory {} could not be created: {source}", root.display())]
101    DirectoryNotCreated {
102        /// The directory that was asked for.
103        root: PathBuf,
104        /// The underlying failure.
105        #[source]
106        source: std::io::Error,
107    },
108
109    /// The download directory exists but cannot be written to.
110    #[error("download directory {} is not writable: {source}", root.display())]
111    DirectoryNotWritable {
112        /// The directory that was probed.
113        root: PathBuf,
114        /// The underlying failure.
115        #[source]
116        source: std::io::Error,
117    },
118
119    /// The name is taken and the conflict policy forbids replacing it.
120    #[error("refusing to replace {}: downloads.conflict is 'error'", path.display())]
121    NameTaken {
122        /// The file that would have been replaced.
123        path: PathBuf,
124    },
125
126    /// Every renamed candidate was taken too.
127    #[error("could not find a free name for \"{name}\" after {attempts} attempts")]
128    NoFreeName {
129        /// The name that was being placed.
130        name: String,
131        /// How many candidates were tried.
132        attempts: usize,
133    },
134
135    /// The caller's validation returned `false`.
136    #[error("\"{name}\" was rejected by the caller's validation")]
137    Rejected {
138        /// The download that was rejected.
139        name: String,
140    },
141
142    /// The caller's validation itself failed.
143    #[error("\"{name}\" could not be validated: {reason}")]
144    ValidationFailed {
145        /// The download that was being validated.
146        name: String,
147        /// The validator's own message, kept verbatim.
148        reason: String,
149    },
150
151    /// Reading or writing a file failed.
152    #[error("{}: {source}", path.display())]
153    Io {
154        /// The file involved.
155        path: PathBuf,
156        /// The underlying failure.
157        #[source]
158        source: std::io::Error,
159    },
160
161    /// No download settled inside the capture's budget.
162    #[error("no download completed within {timeout_ms}ms of the triggering action")]
163    CaptureTimeout {
164        /// The budget that expired, in milliseconds.
165        timeout_ms: u64,
166    },
167
168    /// A download settled without a file.
169    ///
170    /// A failed or cancelled download is never reported as a success, which is
171    /// the whole point of settling a capture on the artifact's state rather
172    /// than on the mere arrival of an event.
173    #[error("download {id} {state}: {failure}")]
174    DownloadFailed {
175        /// Identifier of the download.
176        id: String,
177        /// `failed` or `cancelled`.
178        state: String,
179        /// Whatever the engine or the store reported, kept verbatim.
180        failure: String,
181    },
182
183    /// The action a capture was told to run failed.
184    #[error("the action that was to trigger a download failed: {reason}")]
185    ActionFailed {
186        /// The action's own message, kept verbatim.
187        reason: String,
188    },
189
190    /// Downloads cannot be managed for this engine.
191    #[error("managed downloads are not supported for the {engine} engine: {reason}")]
192    Unsupported {
193        /// Engine the caller asked for.
194        engine: String,
195        /// Why it cannot be supported, rather than a silent no-op.
196        reason: String,
197    },
198
199    /// Talking to the browser failed.
200    #[error("the browser refused to redirect its downloads: {reason}")]
201    Transport {
202        /// The engine's own message, kept verbatim.
203        reason: String,
204    },
205}
206
207#[cfg(test)]
208pub mod test_support {
209    //! Doubles shared by the tests in this module.
210    //!
211    //! They live here rather than in each file so that a watcher test and a
212    //! manager test agree on what a sink does; a double that drifts between
213    //! two test modules is a test that proves nothing.
214
215    use std::path::{Path, PathBuf};
216    use std::sync::Mutex;
217
218    use async_trait::async_trait;
219    use serde_json::Value;
220
221    use crate::downloads::sources::{DownloadFailure, DownloadSink, DownloadStart};
222    use crate::downloads::store::DownloadSource;
223    use crate::fingerprint::CdpTransport;
224
225    /// A directory that removes itself when the test ends.
226    pub struct TempDir(PathBuf);
227
228    impl TempDir {
229        /// Create a uniquely named directory under the system temp directory.
230        pub fn new(name: &str) -> Self {
231            let nanos = std::time::SystemTime::now()
232                .duration_since(std::time::UNIX_EPOCH)
233                .expect("system clock is after the epoch")
234                .as_nanos();
235            let path = std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()));
236            std::fs::create_dir_all(&path).expect("temp directory is writable");
237            Self(path)
238        }
239
240        /// The directory itself.
241        pub fn path(&self) -> &Path {
242            &self.0
243        }
244    }
245
246    impl Drop for TempDir {
247        fn drop(&mut self) {
248            let _ = std::fs::remove_dir_all(&self.0);
249        }
250    }
251
252    /// A CDP transport that records what it was asked to send.
253    #[derive(Default)]
254    pub struct RecordingTransport {
255        sent: Mutex<Vec<(String, Value)>>,
256        refusal: Option<String>,
257    }
258
259    impl RecordingTransport {
260        /// A transport that answers every command with an error.
261        pub fn refusing(reason: &str) -> Self {
262            Self {
263                sent: Mutex::new(Vec::new()),
264                refusal: Some(reason.to_string()),
265            }
266        }
267
268        /// Every command that was sent, in order.
269        pub fn sent(&self) -> Vec<(String, Value)> {
270            self.sent.lock().expect("transport lock").clone()
271        }
272    }
273
274    #[async_trait]
275    impl CdpTransport for RecordingTransport {
276        async fn send(&self, method: &str, params: Value) -> anyhow::Result<Value> {
277            self.sent
278                .lock()
279                .expect("transport lock")
280                .push((method.to_string(), params));
281            match &self.refusal {
282                Some(reason) => Err(anyhow::anyhow!(reason.clone())),
283                None => Ok(Value::Null),
284            }
285        }
286    }
287
288    /// A sink that records the lifecycle calls a source makes.
289    #[derive(Default)]
290    pub struct RecordingSink {
291        started: Mutex<Vec<DownloadStart>>,
292        finished: Mutex<Vec<(String, DownloadSource)>>,
293        failed: Mutex<Vec<(String, DownloadFailure, String)>>,
294    }
295
296    impl RecordingSink {
297        /// The suggested filename of every download that was announced.
298        pub fn started_names(&self) -> Vec<String> {
299            self.started
300                .lock()
301                .expect("sink lock")
302                .iter()
303                .map(|start| start.suggested_filename.clone().unwrap_or_default())
304                .collect()
305        }
306
307        /// How many downloads were handed over for saving.
308        pub fn finished_count(&self) -> usize {
309            self.finished.lock().expect("sink lock").len()
310        }
311
312        /// Every failure that was reported.
313        pub fn failures(&self) -> Vec<(String, DownloadFailure, String)> {
314            self.failed.lock().expect("sink lock").clone()
315        }
316    }
317
318    #[async_trait]
319    impl DownloadSink for RecordingSink {
320        fn started(&self, start: DownloadStart) -> String {
321            let mut started = self.started.lock().expect("sink lock");
322            started.push(start);
323            format!("dl-{:06}", started.len())
324        }
325
326        async fn finished(&self, id: String, source: DownloadSource) {
327            self.finished.lock().expect("sink lock").push((id, source));
328        }
329
330        fn failed(&self, id: String, kind: DownloadFailure, reason: String) {
331            self.failed
332                .lock()
333                .expect("sink lock")
334                .push((id, kind, reason));
335        }
336    }
337}