use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::json;
use crate::cdp::client::CdpClient;
use crate::cdp::types::EvaluateResult;
pub struct DownloadResult {
pub path: String,
pub bytes: usize,
pub mime: String,
}
pub async fn run(
client: &CdpClient,
url: &str,
out: Option<&str>,
timeout_secs: u64,
) -> Result<DownloadResult, crate::BoxError> {
let url_lit = serde_json::to_string(url)?;
let js = format!(
r"(async () => {{
const res = await fetch({url_lit}, {{ credentials: 'include' }});
if (!res.ok) throw new Error('HTTP ' + res.status + ' fetching ' + {url_lit});
const buf = new Uint8Array(await res.arrayBuffer());
let bin = '';
const CHUNK = 0x8000;
for (let i = 0; i < buf.length; i += CHUNK) {{
bin += String.fromCharCode.apply(null, buf.subarray(i, i + CHUNK));
}}
return {{
data: btoa(bin),
mime: res.headers.get('content-type') || '',
cd: res.headers.get('content-disposition') || '',
}};
}})()"
);
let eval: EvaluateResult = tokio::time::timeout(
Duration::from_secs(timeout_secs),
client.call(
"Runtime.evaluate",
json!({ "expression": js, "returnByValue": true, "awaitPromise": true }),
),
)
.await
.map_err(|_| format!("download timed out after {timeout_secs}s fetching {url}"))??;
if let Some(exc) = eval.exception_details {
return Err(format!("download failed: {}", exc.text).into());
}
let obj = eval.result.value.ok_or("download: page returned no data")?;
let data = obj.get("data").and_then(|v| v.as_str()).ok_or("download: missing data")?;
let mime = obj.get("mime").and_then(|v| v.as_str()).unwrap_or("").to_string();
let cd = obj.get("cd").and_then(|v| v.as_str()).unwrap_or("");
let bytes = crate::base64::decode(data)?;
let path = resolve_out_path(out, cd, url)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(DownloadResult {
path: path.display().to_string(),
bytes: bytes.len(),
mime,
})
}
fn resolve_out_path(out: Option<&str>, content_disposition: &str, url: &str) -> Result<PathBuf, crate::BoxError> {
if let Some(o) = out {
return Ok(PathBuf::from(o));
}
let name = filename_from_content_disposition(content_disposition)
.unwrap_or_else(|| filename_from_url(url));
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
Ok(home.join(".chrome-agent").join("tmp").join(name))
}
#[must_use]
pub fn filename_from_url(url: &str) -> String {
let no_query = url.split(['?', '#']).next().unwrap_or(url);
let after_scheme = no_query.split_once("://").map_or(no_query, |(_, rest)| rest);
let path = after_scheme.split_once('/').map_or("", |(_, p)| p);
let last = path.trim_end_matches('/').rsplit('/').next().unwrap_or("").trim();
if last.is_empty() {
"download".to_string()
} else {
sanitize_name(last)
}
}
#[must_use]
pub fn filename_from_content_disposition(header: &str) -> Option<String> {
let lower = header.to_ascii_lowercase();
if let Some(pos) = lower.find("filename*=") {
let raw = &header[pos + "filename*=".len()..];
let value = raw.split(';').next().unwrap_or(raw).trim();
let name = value.rsplit("''").next().unwrap_or(value).trim_matches('"');
let cleaned = sanitize_name(name);
if !cleaned.is_empty() {
return Some(cleaned);
}
}
if let Some(pos) = lower.find("filename=") {
let raw = &header[pos + "filename=".len()..];
let value = raw.split(';').next().unwrap_or(raw).trim().trim_matches('"');
let cleaned = sanitize_name(value);
if !cleaned.is_empty() {
return Some(cleaned);
}
}
None
}
fn sanitize_name(name: &str) -> String {
Path::new(name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_filename_basic() {
assert_eq!(filename_from_url("https://x.com/files/report.pdf"), "report.pdf");
}
#[test]
fn url_filename_strips_query_and_fragment() {
assert_eq!(filename_from_url("https://x.com/a/b/data.csv?v=2&x=1"), "data.csv");
assert_eq!(filename_from_url("https://x.com/a/img.png#frag"), "img.png");
}
#[test]
fn url_filename_trailing_slash_falls_back() {
assert_eq!(filename_from_url("https://x.com/"), "download");
assert_eq!(filename_from_url("https://x.com/dir/"), "dir");
}
#[test]
fn url_filename_cannot_traverse() {
let n = filename_from_url("https://x.com/%2e%2e/etc/passwd");
assert!(!n.contains('/'));
assert_eq!(n, "passwd");
}
#[test]
fn cd_quoted_filename() {
assert_eq!(
filename_from_content_disposition("attachment; filename=\"invoice 2024.pdf\""),
Some("invoice 2024.pdf".to_string())
);
}
#[test]
fn cd_unquoted_filename() {
assert_eq!(
filename_from_content_disposition("attachment; filename=report.csv"),
Some("report.csv".to_string())
);
}
#[test]
fn cd_extended_filename_preferred() {
assert_eq!(
filename_from_content_disposition("attachment; filename=\"fallback.bin\"; filename*=UTF-8''real.pdf"),
Some("real.pdf".to_string())
);
}
#[test]
fn cd_filename_strips_path() {
assert_eq!(
filename_from_content_disposition("attachment; filename=\"../../etc/passwd\""),
Some("passwd".to_string())
);
}
#[test]
fn cd_no_filename_returns_none() {
assert_eq!(filename_from_content_disposition("inline"), None);
assert_eq!(filename_from_content_disposition(""), None);
}
#[test]
fn cd_key_is_case_insensitive() {
assert_eq!(
filename_from_content_disposition("attachment; FileName=report.csv"),
Some("report.csv".to_string())
);
assert_eq!(
filename_from_content_disposition("attachment; FILENAME*=UTF-8''real.pdf"),
Some("real.pdf".to_string())
);
}
#[test]
fn cd_empty_extended_falls_through_to_plain() {
assert_eq!(
filename_from_content_disposition("attachment; filename*=UTF-8''; filename=plain.bin"),
Some("plain.bin".to_string())
);
}
#[test]
fn cd_preserves_percent_escapes_literally() {
let n = filename_from_content_disposition("attachment; filename*=UTF-8''a%2fb.pdf").unwrap();
assert_eq!(n, "a%2fb.pdf");
assert!(!n.contains('/'));
}
#[test]
fn url_filename_host_only_no_slash() {
assert_eq!(filename_from_url("https://x.com"), "download");
}
#[test]
fn resolve_out_honours_explicit_path() {
let p = resolve_out_path(Some("/tmp/mine.bin"), "", "https://x/y.pdf").unwrap();
assert_eq!(p, PathBuf::from("/tmp/mine.bin"));
}
#[test]
fn resolve_out_prefers_cd_over_url() {
let p = resolve_out_path(None, "attachment; filename=from-cd.pdf", "https://x/from-url.pdf").unwrap();
assert!(p.ends_with("from-cd.pdf"));
}
#[test]
fn resolve_out_falls_back_to_url() {
let p = resolve_out_path(None, "inline", "https://x/from-url.pdf").unwrap();
assert!(p.ends_with("from-url.pdf"));
}
}