storebound/apps/desktop/src-tauri/src/downloads.rs

454 lines
19 KiB
Rust

//! 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<FileMap>,
}
#[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<String, String>,
pub targets: HashMap<String, Vec<Archive>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Chrome {
pub endpoint: String,
pub channel: String,
pub platforms: HashMap<String, String>,
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<Manifest, String> {
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<String>,
pub size: Option<u64>,
pub license: String,
pub license_url: String,
pub source: String,
pub installed: bool,
pub folder: String,
}
pub fn info(data_dir: &Path) -> Result<Vec<DownloadInfo>, 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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// Running download jobs and their cancel flags.
#[derive(Default)]
pub struct Jobs {
flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
}
impl Jobs {
pub fn start(&self, job: &str) -> Result<Arc<AtomicBool>, 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<reqwest::Client, String> {
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<String, String> {
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<PathBuf, String> {
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<String> {
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 `<data_dir>/tools`. `emit` gets every progress event.
pub async fn install(data_dir: &Path, tool: &str, job: &str, cancel: Arc<AtomicBool>, emit: impl Fn(Event) + Send + Sync) -> Result<String, String> {
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<u64>, message: Option<String>| 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}")),
}
}