pub mod attach;
pub mod destination;
pub mod manager;
pub mod naming;
pub mod options;
pub mod sources;
pub mod store;
pub mod watcher;
use std::path::PathBuf;
pub use attach::{attach_downloads, normalize_download_options, supported_engine, DownloadSetting};
pub use destination::{
prepare_download_directory, resolve_download_directory, DownloadDirectoryPreset,
ARTIFACT_DIRECTORY_MODE, ARTIFACT_FILE_MODE,
};
pub use manager::{DownloadManager, DEFAULT_CAPTURE_TIMEOUT};
pub use naming::{
extension_from_content, is_inside_root, renamed_candidate, resolve_inside_root,
sanitize_download_name, with_extension,
};
pub use options::{CaptureOptions, DownloadArtifact, DownloadEvent, DownloadOptions};
pub use sources::{
classify_failure, prepare_staging_directory, set_download_behavior, DownloadFailure,
DownloadSink, DownloadStart, SourceHandle, STAGING_DIRECTORY,
};
pub use store::{
clean_partials, resolve_final_path, save_download, DownloadCandidate, DownloadConflict,
DownloadNamer, DownloadNaming, DownloadSource, DownloadValidator, SaveRequest, SavedDownload,
};
pub use watcher::{
attach_filesystem_watcher, DirectoryWatcher, StagedDownload, DEFAULT_POLL_INTERVAL,
IN_PROGRESS_SUFFIXES,
};
#[derive(Debug, thiserror::Error)]
pub enum DownloadError {
#[error("refusing to write \"{name}\" outside the download directory {}", root.display())]
OutsideRoot {
name: String,
root: PathBuf,
},
#[error("downloads.directory must be absolute, received \"{directory}\"")]
RelativeDirectory {
directory: String,
},
#[error("download directory {} could not be created: {source}", root.display())]
DirectoryNotCreated {
root: PathBuf,
#[source]
source: std::io::Error,
},
#[error("download directory {} is not writable: {source}", root.display())]
DirectoryNotWritable {
root: PathBuf,
#[source]
source: std::io::Error,
},
#[error("refusing to replace {}: downloads.conflict is 'error'", path.display())]
NameTaken {
path: PathBuf,
},
#[error("could not find a free name for \"{name}\" after {attempts} attempts")]
NoFreeName {
name: String,
attempts: usize,
},
#[error("\"{name}\" was rejected by the caller's validation")]
Rejected {
name: String,
},
#[error("\"{name}\" could not be validated: {reason}")]
ValidationFailed {
name: String,
reason: String,
},
#[error("{}: {source}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("no download completed within {timeout_ms}ms of the triggering action")]
CaptureTimeout {
timeout_ms: u64,
},
#[error("download {id} {state}: {failure}")]
DownloadFailed {
id: String,
state: String,
failure: String,
},
#[error("the action that was to trigger a download failed: {reason}")]
ActionFailed {
reason: String,
},
#[error("managed downloads are not supported for the {engine} engine: {reason}")]
Unsupported {
engine: String,
reason: String,
},
#[error("the browser refused to redirect its downloads: {reason}")]
Transport {
reason: String,
},
}
#[cfg(test)]
pub mod test_support {
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use async_trait::async_trait;
use serde_json::Value;
use crate::downloads::sources::{DownloadFailure, DownloadSink, DownloadStart};
use crate::downloads::store::DownloadSource;
use crate::fingerprint::CdpTransport;
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(name: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&path).expect("temp directory is writable");
Self(path)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[derive(Default)]
pub struct RecordingTransport {
sent: Mutex<Vec<(String, Value)>>,
refusal: Option<String>,
}
impl RecordingTransport {
pub fn refusing(reason: &str) -> Self {
Self {
sent: Mutex::new(Vec::new()),
refusal: Some(reason.to_string()),
}
}
pub fn sent(&self) -> Vec<(String, Value)> {
self.sent.lock().expect("transport lock").clone()
}
}
#[async_trait]
impl CdpTransport for RecordingTransport {
async fn send(&self, method: &str, params: Value) -> anyhow::Result<Value> {
self.sent
.lock()
.expect("transport lock")
.push((method.to_string(), params));
match &self.refusal {
Some(reason) => Err(anyhow::anyhow!(reason.clone())),
None => Ok(Value::Null),
}
}
}
#[derive(Default)]
pub struct RecordingSink {
started: Mutex<Vec<DownloadStart>>,
finished: Mutex<Vec<(String, DownloadSource)>>,
failed: Mutex<Vec<(String, DownloadFailure, String)>>,
}
impl RecordingSink {
pub fn started_names(&self) -> Vec<String> {
self.started
.lock()
.expect("sink lock")
.iter()
.map(|start| start.suggested_filename.clone().unwrap_or_default())
.collect()
}
pub fn finished_count(&self) -> usize {
self.finished.lock().expect("sink lock").len()
}
pub fn failures(&self) -> Vec<(String, DownloadFailure, String)> {
self.failed.lock().expect("sink lock").clone()
}
}
#[async_trait]
impl DownloadSink for RecordingSink {
fn started(&self, start: DownloadStart) -> String {
let mut started = self.started.lock().expect("sink lock");
started.push(start);
format!("dl-{:06}", started.len())
}
async fn finished(&self, id: String, source: DownloadSource) {
self.finished.lock().expect("sink lock").push((id, source));
}
fn failed(&self, id: String, kind: DownloadFailure, reason: String) {
self.failed
.lock()
.expect("sink lock")
.push((id, kind, reason));
}
}
}