//! One-click downloads of the optional local tools (Dependencies screen) into the app data folder, never system-wide //! and never bundled with the installer (docs/desktop.md): //! //! - **ffmpeg + ffprobe**: the exact archives of `tools-manifest.json` (URL, SHA-256, size; written by //! tools/tools-manifest.ts per desktop release). The SHA-256 is checked while downloading; a mismatch deletes the file. //! - **Chrome for Testing**: Google's official JSON endpoint (Stable channel), archive from storage.googleapis.com. No //! hashes are published, so the unpacked browser must carry Google's code signature (macOS team EQHXZ8M8AV, //! Windows Authenticode by Google LLC); on Linux the version banner is checked. //! //! Everything happens in a staging folder; only a verified result replaces the installed copy. Progress goes to the //! window as `tool-download` events with the job id; cancel removes the staging folder. Redirects may only lead to //! the hosts in DOWNLOAD_HOSTS, over HTTPS. use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use url::Url; pub const MANIFEST_JSON: &str = include_str!("../tools-manifest.json"); /// Hosts a download (or one of its redirects) may touch. pub const DOWNLOAD_HOSTS: &[&str] = &[ "ffmpeg.martin-riedl.de", "github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com", "googlechromelabs.github.io", "storage.googleapis.com", ]; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FileMap { pub from: String, pub to: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Archive { pub url: String, pub sha256: String, pub size: u64, pub kind: String, pub files: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Ffmpeg { pub version: String, pub license: String, pub license_url: String, pub sources: HashMap, pub targets: HashMap>, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Chrome { pub endpoint: String, pub channel: String, pub platforms: HashMap, pub mac_team_id: String, pub windows_signer: String, pub license: String, pub license_url: String, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Manifest { pub generated: String, pub ffmpeg: Ffmpeg, pub chrome: Chrome, } pub fn manifest() -> Result { serde_json::from_str(MANIFEST_JSON).map_err(|e| format!("tools manifest: {e}")) } /// Rust target triple of this build (the manifest's keys). pub fn target_triple() -> &'static str { if cfg!(all(target_os = "macos", target_arch = "aarch64")) { "aarch64-apple-darwin" } else if cfg!(target_os = "macos") { "x86_64-apple-darwin" } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { "aarch64-unknown-linux-gnu" } else if cfg!(target_os = "linux") { "x86_64-unknown-linux-gnu" } else { "x86_64-pc-windows-msvc" } } pub fn download_allowed(u: &Url) -> bool { u.scheme() == "https" && u.username().is_empty() && u.password().is_none() && u.port().is_none() && u.host_str().is_some_and(|h| DOWNLOAD_HOSTS.contains(&h)) } pub fn hex(b: &[u8]) -> String { crate::confirm::hex(b) } /// What the Dependencies screen shows next to the Download buttons. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DownloadInfo { pub tool: &'static str, pub available: bool, pub version: Option, pub size: Option, pub license: String, pub license_url: String, pub source: String, pub installed: bool, pub folder: String, } pub fn info(data_dir: &Path) -> Result, String> { let m = manifest()?; let t = target_triple(); let ff = m.ffmpeg.targets.get(t); let os = if t.contains("apple") { "macos" } else if t.contains("windows") { "windows" } else { "linux" }; let exe = if cfg!(windows) { ".exe" } else { "" }; Ok(vec![ DownloadInfo { tool: "ffmpeg", available: ff.is_some(), version: Some(m.ffmpeg.version.clone()), size: ff.map(|a| a.iter().map(|x| x.size).sum()), license: m.ffmpeg.license.clone(), license_url: m.ffmpeg.license_url.clone(), source: m.ffmpeg.sources.get(os).cloned().unwrap_or_default(), installed: data_dir.join("tools").join("bin").join(format!("ffmpeg{exe}")).is_file(), folder: "tools/bin".into(), }, DownloadInfo { tool: "chrome", available: m.chrome.platforms.contains_key(t), version: None, size: None, license: m.chrome.license.clone(), license_url: m.chrome.license_url.clone(), source: "https://googlechromelabs.github.io/chrome-for-testing/".into(), installed: data_dir.join("tools").join("chrome").join(crate::policy::chrome_executable()).is_file(), folder: "tools/chrome".into(), }, ]) } /// Progress of a download job, sent to the window as `tool-download`. #[derive(Debug, Clone, Serialize)] pub struct Event { pub job: String, pub tool: String, /// resolve, download, verify, unpack, install, done, error, cancelled pub stage: &'static str, pub received: u64, pub total: Option, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, } /// Running download jobs and their cancel flags. #[derive(Default)] pub struct Jobs { flags: Mutex>>, } impl Jobs { pub fn start(&self, job: &str) -> Result, String> { let mut m = self.flags.lock().map_err(|_| "jobs poisoned")?; if m.contains_key(job) { return Err("this download is already running".into()); } let f = Arc::new(AtomicBool::new(false)); m.insert(job.to_string(), f.clone()); Ok(f) } pub fn finish(&self, job: &str) { if let Ok(mut m) = self.flags.lock() { m.remove(job); } } pub fn cancel(&self, job: &str) -> bool { self.flags.lock().ok().and_then(|m| m.get(job).map(|f| f.store(true, Ordering::SeqCst))).is_some() } } /// The Chrome for Testing download of a platform from the endpoint JSON: (version, url). pub fn chrome_download(endpoint_json: &serde_json::Value, channel: &str, platform: &str) -> Result<(String, Url), String> { let ch = &endpoint_json["channels"][channel]; let version = ch["version"].as_str().ok_or("Chrome for Testing: no version in the endpoint answer")?.to_string(); let url = ch["downloads"]["chrome"] .as_array() .and_then(|a| a.iter().find(|d| d["platform"].as_str() == Some(platform))) .and_then(|d| d["url"].as_str()) .ok_or_else(|| format!("Chrome for Testing: no download for {platform}"))?; let u = Url::parse(url).map_err(|e| e.to_string())?; if !download_allowed(&u) { return Err(format!("Chrome for Testing: unexpected download host {}", u.host_str().unwrap_or("?"))); } Ok((version, u)) } fn client() -> Result { let _ = rustls::crypto::ring::default_provider().install_default(); reqwest::Client::builder() .user_agent(concat!("storebound-desktop/", env!("CARGO_PKG_VERSION"))) .redirect(reqwest::redirect::Policy::custom(|a| { if a.previous().len() > 8 { a.error("too many redirects") } else if download_allowed(a.url()) { a.follow() } else { a.error("redirect to a host outside the download list") } })) .connect_timeout(std::time::Duration::from_secs(20)) .build() .map_err(|e| e.to_string()) } /// Streams `url` into `dest`, returning its SHA-256 (hex). `progress(received_in_this_file)` after each chunk. async fn fetch(c: &reqwest::Client, url: &Url, dest: &Path, cancel: &AtomicBool, mut progress: impl FnMut(u64)) -> Result { if !download_allowed(url) { return Err(format!("not an allowed download host: {}", url.host_str().unwrap_or("?"))); } let mut resp = c.get(url.clone()).send().await.map_err(|e| format!("download: {e}"))?; if !resp.status().is_success() { return Err(format!("download: {} answered {}", url.host_str().unwrap_or("?"), resp.status())); } let mut f = fs::File::create(dest).map_err(|e| e.to_string())?; let mut h = Sha256::new(); let mut got: u64 = 0; while let Some(chunk) = resp.chunk().await.map_err(|e| format!("download: {e}"))? { if cancel.load(Ordering::SeqCst) { return Err("cancelled".into()); } h.update(&chunk); f.write_all(&chunk).map_err(|e| e.to_string())?; got += chunk.len() as u64; progress(got); } f.flush().map_err(|e| e.to_string())?; Ok(hex(&h.finalize())) } /// Unpacks an archive into `dir`. macOS: ditto (keeps the app bundles' symlinks and signatures intact); tar.xz: the /// system tar; other zips: the zip crate, which refuses entries outside `dir`. pub fn unpack(archive: &Path, kind: &str, dir: &Path) -> Result<(), String> { fs::create_dir_all(dir).map_err(|e| e.to_string())?; let run = |cmd: &mut std::process::Command| -> Result<(), String> { let out = cmd.output().map_err(|e| e.to_string())?; if out.status.success() { Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().chars().take(300).collect()) } }; match kind { "tar.xz" => run(std::process::Command::new("tar").arg("-xJf").arg(archive).arg("-C").arg(dir)), "zip" if cfg!(target_os = "macos") => run(std::process::Command::new("/usr/bin/ditto").arg("-x").arg("-k").arg(archive).arg(dir)), "zip" => { let f = fs::File::open(archive).map_err(|e| e.to_string())?; let mut z = zip::ZipArchive::new(f).map_err(|e| e.to_string())?; z.extract(dir).map_err(|e| e.to_string()) } k => Err(format!("unknown archive kind {k}")), } } /// `rel` inside `base`, refusing absolute paths and `..`. pub fn inside(base: &Path, rel: &str) -> Result { let p = Path::new(rel); if p.is_absolute() || p.components().any(|c| !matches!(c, std::path::Component::Normal(_))) { return Err(format!("unsafe path in the manifest: {rel}")); } Ok(base.join(p)) } fn first_line(exe: &Path, arg: &str) -> Option { let out = std::process::Command::new(exe).arg(arg).output().ok()?; let text = if out.stdout.is_empty() { out.stderr } else { out.stdout }; String::from_utf8_lossy(&text).lines().next().map(|s| s.trim().to_string()) } #[cfg(unix)] fn make_executable(p: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(p, fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string()) } #[cfg(not(unix))] fn make_executable(_: &Path) -> Result<(), String> { Ok(()) } /// Google's code signature on the unpacked Chrome for Testing. fn verify_chrome(root: &Path, exe: &Path, c: &Chrome) -> Result<(), String> { if cfg!(target_os = "macos") { let app = root.join("Google Chrome for Testing.app"); let v = std::process::Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(&app).output().map_err(|e| e.to_string())?; if !v.status.success() { return Err(format!("code signature invalid: {}", String::from_utf8_lossy(&v.stderr).trim())); } let d = std::process::Command::new("/usr/bin/codesign").args(["-dv", "--verbose=2"]).arg(&app).output().map_err(|e| e.to_string())?; let text = String::from_utf8_lossy(&d.stderr); if !text.lines().any(|l| l.trim() == format!("TeamIdentifier={}", c.mac_team_id)) { return Err("not signed by Google (team identifier differs)".into()); } Ok(()) } else if cfg!(windows) { let script = format!( "$s = Get-AuthenticodeSignature -LiteralPath '{}'; if ($s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O={}*') {{ exit 0 }} else {{ exit 1 }}", exe.display().to_string().replace('\'', "''"), c.windows_signer ); let st = std::process::Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", &script]).status().map_err(|e| e.to_string())?; if st.success() { Ok(()) } else { Err(format!("Authenticode signature by {} not found", c.windows_signer)) } } else { match first_line(exe, "--version") { Some(l) if l.starts_with("Google Chrome for Testing") => Ok(()), other => Err(format!("unexpected version banner: {}", other.unwrap_or_default())), } } } /// Installs `tool` ("ffmpeg" or "chrome") into `/tools`. `emit` gets every progress event. pub async fn install(data_dir: &Path, tool: &str, job: &str, cancel: Arc, emit: impl Fn(Event) + Send + Sync) -> Result { let m = manifest()?; let tools = data_dir.join("tools"); let staging = tools.join(format!(".staging-{job}")); let _ = fs::remove_dir_all(&staging); fs::create_dir_all(&staging).map_err(|e| e.to_string())?; let ev = |stage: &'static str, received: u64, total: Option, message: Option| emit(Event { job: job.into(), tool: tool.into(), stage, received, total, message }); let res = async { let c = client()?; match tool { "ffmpeg" => { let archives = m.ffmpeg.targets.get(target_triple()).ok_or("no ffmpeg build for this platform in the manifest")?; let total: u64 = archives.iter().map(|a| a.size).sum(); let mut done: u64 = 0; let bin = staging.join("bin"); fs::create_dir_all(&bin).map_err(|e| e.to_string())?; for (i, a) in archives.iter().enumerate() { let url = Url::parse(&a.url).map_err(|e| e.to_string())?; let file = staging.join(format!("a{i}.{}", a.kind)); let sum = fetch(&c, &url, &file, &cancel, |n| ev("download", done + n, Some(total), None)).await?; ev("verify", done + a.size, Some(total), None); if !sum.eq_ignore_ascii_case(&a.sha256) { let _ = fs::remove_file(&file); return Err(format!("checksum mismatch for {} (deleted)", url.path())); } done += a.size; ev("unpack", done, Some(total), None); let out = staging.join(format!("u{i}")); unpack(&file, &a.kind, &out)?; for f in &a.files { let src = inside(&out, &f.from)?; let dst = inside(&bin, &f.to)?; if !src.is_file() { return Err(format!("{} missing in the archive", f.from)); } fs::rename(&src, &dst).or_else(|_| fs::copy(&src, &dst).map(|_| ())).map_err(|e| e.to_string())?; make_executable(&dst)?; } let _ = fs::remove_file(&file); } let exe = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }; let banner = first_line(&bin.join(exe), "-version").unwrap_or_default(); if !banner.starts_with("ffmpeg version") { return Err(format!("the downloaded ffmpeg does not run: {banner}")); } ev("install", total, Some(total), None); let dest = tools.join("bin"); fs::create_dir_all(&dest).map_err(|e| e.to_string())?; for e in fs::read_dir(&bin).map_err(|e| e.to_string())? { let e = e.map_err(|e| e.to_string())?; let target = dest.join(e.file_name()); let _ = fs::remove_file(&target); fs::rename(e.path(), &target).map_err(|e| e.to_string())?; } Ok(banner) } "chrome" => { let platform = m.chrome.platforms.get(target_triple()).ok_or("Chrome for Testing has no build for this platform")?; ev("resolve", 0, None, None); let endpoint = Url::parse(&m.chrome.endpoint).map_err(|e| e.to_string())?; if !download_allowed(&endpoint) { return Err("endpoint host not allowed".into()); } let json: serde_json::Value = c.get(endpoint).send().await.map_err(|e| e.to_string())?.json().await.map_err(|e| e.to_string())?; let (version, url) = chrome_download(&json, &m.chrome.channel, platform)?; let head = c.head(url.clone()).send().await.ok(); let total = head.and_then(|h| h.content_length()); let file = staging.join("chrome.zip"); fetch(&c, &url, &file, &cancel, |n| ev("download", n, total, Some(version.clone()))).await?; ev("unpack", total.unwrap_or(0), total, None); let out = staging.join("u"); unpack(&file, "zip", &out)?; let _ = fs::remove_file(&file); let root = out.join(format!("chrome-{platform}")); let exe = root.join(crate::policy::chrome_executable()); if !exe.is_file() { return Err("Chrome for Testing archive has an unexpected layout".into()); } ev("verify", total.unwrap_or(0), total, None); verify_chrome(&root, &exe, &m.chrome)?; ev("install", total.unwrap_or(0), total, None); let dest = tools.join("chrome"); let _ = fs::remove_dir_all(&dest); fs::rename(&root, &dest).map_err(|e| e.to_string())?; Ok(format!("Chrome for Testing {version}")) } other => Err(format!("unknown tool {other}")), } } .await; let _ = fs::remove_dir_all(&staging); match &res { Ok(v) => ev("done", 0, None, Some(v.clone())), Err(e) if e == "cancelled" => ev("cancelled", 0, None, None), Err(e) => ev("error", 0, None, Some(e.clone())), } res } /// Removes an app-managed tool. pub fn uninstall(data_dir: &Path, tool: &str) -> Result<(), String> { let tools = data_dir.join("tools"); match tool { "ffmpeg" => { for n in ["ffmpeg", "ffprobe", "ffmpeg.exe", "ffprobe.exe"] { let _ = fs::remove_file(tools.join("bin").join(n)); } Ok(()) } "chrome" => { let p = tools.join("chrome"); if p.exists() { fs::remove_dir_all(p).map_err(|e| e.to_string())?; } Ok(()) } other => Err(format!("unknown tool {other}")), } }