454 lines
19 KiB
Rust
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}")),
|
|
}
|
|
}
|