use std::{
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
apply_patch, default_platform, download_to_file, read_url_to_string, sha256_file,
verify_manifest_signature, verify_sha256, ApplyPatchOptions, Artifact, DeltaArtifact,
DownloadEvent, Error, FileDigest, HttpHeader, Result, SelectedArtifact, UpdateManifest,
DEFAULT_ALGORITHM,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrepareUpdateOptions {
pub manifest_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_artifact_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hpatchz_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_public_key: Option<String>,
#[serde(default = "default_true")]
pub require_signature: bool,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub headers: Vec<HttpHeader>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PreparedUpdateKind {
Delta,
Full,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreparedUpdate {
pub version: String,
pub platform: String,
pub kind: PreparedUpdateKind,
pub artifact_path: PathBuf,
pub artifact: FileDigest,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patch_path: Option<PathBuf>,
pub bytes_downloaded: u64,
pub manifest: UpdateManifest,
}
pub async fn load_update_manifest(
manifest_url: &str,
headers: &[HttpHeader],
timeout_secs: Option<u64>,
signature_public_key: Option<&str>,
require_signature: bool,
expected_version: Option<&str>,
) -> Result<UpdateManifest> {
let manifest_text = read_url_to_string(manifest_url, headers, timeout_secs).await?;
let manifest: UpdateManifest = serde_json::from_str(&manifest_text)?;
match signature_public_key {
Some(public_key) => verify_manifest_signature(&manifest, public_key)?,
None if require_signature => return Err(Error::SignaturePublicKeyMissing),
None => {}
}
if let Some(expected_version) = expected_version {
if manifest.version != expected_version {
return Err(Error::UnexpectedVersion {
expected: expected_version.to_string(),
actual: manifest.version,
});
}
}
Ok(manifest)
}
pub async fn prepare_update<F>(
options: PrepareUpdateOptions,
mut on_event: F,
) -> Result<PreparedUpdate>
where
F: FnMut(DownloadEvent),
{
let platform = options.platform.clone().unwrap_or_else(default_platform);
let manifest = load_update_manifest(
&options.manifest_url,
&options.headers,
options.timeout_secs,
options.signature_public_key.as_deref(),
options.require_signature,
options.expected_version.as_deref(),
)
.await?;
let platform_release = manifest.platform(&platform)?;
let current_digest = match &options.current_artifact_path {
Some(path) => Some(sha256_file(path)?),
None => None,
};
let selected = manifest.select_artifact(
&platform,
options.current_version.as_deref(),
current_digest.as_ref().map(|digest| digest.sha256.as_str()),
)?;
let cache_dir = options.cache_dir.clone().unwrap_or_else(default_cache_dir);
match selected {
SelectedArtifact::Delta { artifact } => {
let current_path = options.current_artifact_path.as_ref().ok_or_else(|| {
Error::NoMatchingArtifact {
platform: platform.clone(),
current_version: options.current_version.clone(),
current_sha256: None,
}
})?;
prepare_delta(
&options,
&manifest,
platform_release.full.clone(),
artifact,
&platform,
current_path,
&cache_dir,
&mut on_event,
)
.await
}
SelectedArtifact::Full { artifact } => {
prepare_full(
&options,
&manifest,
artifact,
&platform,
&cache_dir,
&mut on_event,
)
.await
}
}
}
pub async fn prepare_delta_update<F>(
options: PrepareUpdateOptions,
mut on_event: F,
) -> Result<PreparedUpdate>
where
F: FnMut(DownloadEvent),
{
let platform = options.platform.clone().unwrap_or_else(default_platform);
let manifest = load_update_manifest(
&options.manifest_url,
&options.headers,
options.timeout_secs,
options.signature_public_key.as_deref(),
options.require_signature,
options.expected_version.as_deref(),
)
.await?;
let platform_release = manifest.platform(&platform)?;
let current_path =
options
.current_artifact_path
.as_ref()
.ok_or_else(|| Error::NoMatchingArtifact {
platform: platform.clone(),
current_version: options.current_version.clone(),
current_sha256: None,
})?;
let current_digest = sha256_file(current_path)?;
let selected = manifest.select_artifact(
&platform,
options.current_version.as_deref(),
Some(current_digest.sha256.as_str()),
)?;
let cache_dir = options.cache_dir.clone().unwrap_or_else(default_cache_dir);
match selected {
SelectedArtifact::Delta { artifact } => {
prepare_delta(
&options,
&manifest,
platform_release.full.clone(),
artifact,
&platform,
current_path,
&cache_dir,
&mut on_event,
)
.await
}
SelectedArtifact::Full { .. } => Err(Error::NoMatchingArtifact {
platform,
current_version: options.current_version.clone(),
current_sha256: Some(current_digest.sha256),
}),
}
}
async fn prepare_delta<F>(
options: &PrepareUpdateOptions,
manifest: &UpdateManifest,
full_artifact: Artifact,
delta: DeltaArtifact,
platform: &str,
current_path: &Path,
cache_dir: &Path,
on_event: &mut F,
) -> Result<PreparedUpdate>
where
F: FnMut(DownloadEvent),
{
if delta.algorithm != DEFAULT_ALGORITHM {
return Err(Error::UnsupportedAlgorithm(delta.algorithm));
}
let patch_path = options.patch_path.clone().unwrap_or_else(|| {
cache_dir.join(url_file_name(&delta.url).unwrap_or_else(|| {
format!(
"{}-to-{}.hpatch",
delta
.from_version
.clone()
.unwrap_or_else(|| "current".to_string()),
manifest.version
)
}))
});
let patch_url = resolve_artifact_url(&options.manifest_url, &delta.url);
let stats = download_to_file(
&patch_url,
&patch_path,
&options.headers,
options.timeout_secs,
on_event,
)
.await?;
verify_size(&patch_path, delta.size, stats.bytes_written)?;
verify_sha256(&patch_path, &delta.sha256)?;
let output_path = options.output_path.clone().unwrap_or_else(|| {
cache_dir.join(
url_file_name(&full_artifact.url)
.unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
)
});
let expected = delta
.target_sha256
.clone()
.unwrap_or_else(|| full_artifact.sha256.clone());
let apply = apply_patch(&ApplyPatchOptions {
old_path: current_path.to_path_buf(),
patch_path: patch_path.clone(),
output_path: output_path.clone(),
hpatchz_path: options.hpatchz_path.clone(),
force: options.force,
expected_sha256: Some(expected),
cache_size: None,
parallel_threads: Some(4),
verify_checksums: true,
})?;
if let Some(target_size) = delta.target_size {
verify_size(&apply.output.path, target_size, apply.output.size)?;
}
Ok(PreparedUpdate {
version: manifest.version.clone(),
platform: platform.to_string(),
kind: PreparedUpdateKind::Delta,
artifact_path: output_path,
artifact: apply.output,
patch_path: Some(patch_path),
bytes_downloaded: stats.bytes_written,
manifest: manifest.clone(),
})
}
async fn prepare_full<F>(
options: &PrepareUpdateOptions,
manifest: &UpdateManifest,
artifact: Artifact,
platform: &str,
cache_dir: &Path,
on_event: &mut F,
) -> Result<PreparedUpdate>
where
F: FnMut(DownloadEvent),
{
let output_path = options.output_path.clone().unwrap_or_else(|| {
cache_dir.join(
url_file_name(&artifact.url)
.unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
)
});
let artifact_url = resolve_artifact_url(&options.manifest_url, &artifact.url);
let stats = download_to_file(
&artifact_url,
&output_path,
&options.headers,
options.timeout_secs,
on_event,
)
.await?;
verify_size(&output_path, artifact.size, stats.bytes_written)?;
let digest = verify_sha256(&output_path, &artifact.sha256)?;
Ok(PreparedUpdate {
version: manifest.version.clone(),
platform: platform.to_string(),
kind: PreparedUpdateKind::Full,
artifact_path: output_path,
artifact: digest,
patch_path: None,
bytes_downloaded: stats.bytes_written,
manifest: manifest.clone(),
})
}
fn resolve_artifact_url(manifest_url: &str, artifact_url: &str) -> String {
if Url::parse(artifact_url).is_ok() || Path::new(artifact_url).is_absolute() {
return artifact_url.to_string();
}
if let Ok(base_url) = Url::parse(manifest_url) {
if let Ok(joined) = base_url.join(artifact_url) {
return joined.to_string();
}
}
let base = Path::new(manifest_url)
.parent()
.unwrap_or_else(|| Path::new("."));
base.join(artifact_url).to_string_lossy().to_string()
}
fn url_file_name(url_or_path: &str) -> Option<String> {
if let Ok(url) = Url::parse(url_or_path) {
return url
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned);
}
Path::new(url_or_path)
.file_name()
.map(|name| name.to_string_lossy().to_string())
}
fn default_cache_dir() -> PathBuf {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
std::env::temp_dir()
.join("tauri-hdiff-update")
.join(millis.to_string())
}
fn verify_size(path: &Path, expected: u64, actual: u64) -> Result<()> {
if expected != actual {
return Err(Error::SizeMismatch {
path: path.to_path_buf(),
expected,
actual,
});
}
Ok(())
}
fn default_true() -> bool {
true
}