hdiff-update-core 0.1.0

Core library for signed HDiffPatch-based differential application updates.
Documentation
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 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 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_text = read_url_to_string(
        &options.manifest_url,
        &options.headers,
        options.timeout_secs,
    )
    .await?;
    let manifest: UpdateManifest = serde_json::from_str(&manifest_text)?;
    match options.signature_public_key.as_deref() {
        Some(public_key) => verify_manifest_signature(&manifest, public_key)?,
        None if options.require_signature => return Err(Error::SignaturePublicKeyMissing),
        None => {}
    }
    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
        }
    }
}

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
}