browser_commander/downloads/
mod.rs1pub 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#[derive(Debug, thiserror::Error)]
82pub enum DownloadError {
83 #[error("refusing to write \"{name}\" outside the download directory {}", root.display())]
85 OutsideRoot {
86 name: String,
88 root: PathBuf,
90 },
91
92 #[error("downloads.directory must be absolute, received \"{directory}\"")]
94 RelativeDirectory {
95 directory: String,
97 },
98
99 #[error("download directory {} could not be created: {source}", root.display())]
101 DirectoryNotCreated {
102 root: PathBuf,
104 #[source]
106 source: std::io::Error,
107 },
108
109 #[error("download directory {} is not writable: {source}", root.display())]
111 DirectoryNotWritable {
112 root: PathBuf,
114 #[source]
116 source: std::io::Error,
117 },
118
119 #[error("refusing to replace {}: downloads.conflict is 'error'", path.display())]
121 NameTaken {
122 path: PathBuf,
124 },
125
126 #[error("could not find a free name for \"{name}\" after {attempts} attempts")]
128 NoFreeName {
129 name: String,
131 attempts: usize,
133 },
134
135 #[error("\"{name}\" was rejected by the caller's validation")]
137 Rejected {
138 name: String,
140 },
141
142 #[error("\"{name}\" could not be validated: {reason}")]
144 ValidationFailed {
145 name: String,
147 reason: String,
149 },
150
151 #[error("{}: {source}", path.display())]
153 Io {
154 path: PathBuf,
156 #[source]
158 source: std::io::Error,
159 },
160
161 #[error("no download completed within {timeout_ms}ms of the triggering action")]
163 CaptureTimeout {
164 timeout_ms: u64,
166 },
167
168 #[error("download {id} {state}: {failure}")]
174 DownloadFailed {
175 id: String,
177 state: String,
179 failure: String,
181 },
182
183 #[error("the action that was to trigger a download failed: {reason}")]
185 ActionFailed {
186 reason: String,
188 },
189
190 #[error("managed downloads are not supported for the {engine} engine: {reason}")]
192 Unsupported {
193 engine: String,
195 reason: String,
197 },
198
199 #[error("the browser refused to redirect its downloads: {reason}")]
201 Transport {
202 reason: String,
204 },
205}
206
207#[cfg(test)]
208pub mod test_support {
209 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 pub struct TempDir(PathBuf);
227
228 impl TempDir {
229 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 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 #[derive(Default)]
254 pub struct RecordingTransport {
255 sent: Mutex<Vec<(String, Value)>>,
256 refusal: Option<String>,
257 }
258
259 impl RecordingTransport {
260 pub fn refusing(reason: &str) -> Self {
262 Self {
263 sent: Mutex::new(Vec::new()),
264 refusal: Some(reason.to_string()),
265 }
266 }
267
268 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 #[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 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 pub fn finished_count(&self) -> usize {
309 self.finished.lock().expect("sink lock").len()
310 }
311
312 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}