Skip to main content

hdiff_update_core/
update.rs

1use std::{
2    path::{Path, PathBuf},
3    time::{SystemTime, UNIX_EPOCH},
4};
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::{
10    apply_patch, default_platform, download_to_file, read_url_to_string, sha256_file,
11    verify_manifest_signature, verify_sha256, ApplyPatchOptions, Artifact, DeltaArtifact,
12    DownloadEvent, Error, FileDigest, HttpHeader, Result, SelectedArtifact, UpdateManifest,
13    DEFAULT_ALGORITHM,
14};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct PrepareUpdateOptions {
19    pub manifest_url: String,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub platform: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub current_version: Option<String>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub expected_version: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub current_artifact_path: Option<PathBuf>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub output_path: Option<PathBuf>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub patch_path: Option<PathBuf>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub cache_dir: Option<PathBuf>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub hpatchz_path: Option<PathBuf>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub signature_public_key: Option<String>,
38    #[serde(default = "default_true")]
39    pub require_signature: bool,
40    #[serde(default)]
41    pub force: bool,
42    #[serde(default)]
43    pub headers: Vec<HttpHeader>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub timeout_secs: Option<u64>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase")]
50pub enum PreparedUpdateKind {
51    Delta,
52    Full,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct PreparedUpdate {
58    pub version: String,
59    pub platform: String,
60    pub kind: PreparedUpdateKind,
61    pub artifact_path: PathBuf,
62    pub artifact: FileDigest,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub patch_path: Option<PathBuf>,
65    pub bytes_downloaded: u64,
66    pub manifest: UpdateManifest,
67}
68
69pub async fn load_update_manifest(
70    manifest_url: &str,
71    headers: &[HttpHeader],
72    timeout_secs: Option<u64>,
73    signature_public_key: Option<&str>,
74    require_signature: bool,
75    expected_version: Option<&str>,
76) -> Result<UpdateManifest> {
77    let manifest_text = read_url_to_string(manifest_url, headers, timeout_secs).await?;
78    let manifest: UpdateManifest = serde_json::from_str(&manifest_text)?;
79    match signature_public_key {
80        Some(public_key) => verify_manifest_signature(&manifest, public_key)?,
81        None if require_signature => return Err(Error::SignaturePublicKeyMissing),
82        None => {}
83    }
84    if let Some(expected_version) = expected_version {
85        if manifest.version != expected_version {
86            return Err(Error::UnexpectedVersion {
87                expected: expected_version.to_string(),
88                actual: manifest.version,
89            });
90        }
91    }
92    Ok(manifest)
93}
94
95pub async fn prepare_update<F>(
96    options: PrepareUpdateOptions,
97    mut on_event: F,
98) -> Result<PreparedUpdate>
99where
100    F: FnMut(DownloadEvent),
101{
102    let platform = options.platform.clone().unwrap_or_else(default_platform);
103    let manifest = load_update_manifest(
104        &options.manifest_url,
105        &options.headers,
106        options.timeout_secs,
107        options.signature_public_key.as_deref(),
108        options.require_signature,
109        options.expected_version.as_deref(),
110    )
111    .await?;
112    let platform_release = manifest.platform(&platform)?;
113    let current_digest = match &options.current_artifact_path {
114        Some(path) => Some(sha256_file(path)?),
115        None => None,
116    };
117    let selected = manifest.select_artifact(
118        &platform,
119        options.current_version.as_deref(),
120        current_digest.as_ref().map(|digest| digest.sha256.as_str()),
121    )?;
122    let cache_dir = options.cache_dir.clone().unwrap_or_else(default_cache_dir);
123
124    match selected {
125        SelectedArtifact::Delta { artifact } => {
126            let current_path = options.current_artifact_path.as_ref().ok_or_else(|| {
127                Error::NoMatchingArtifact {
128                    platform: platform.clone(),
129                    current_version: options.current_version.clone(),
130                    current_sha256: None,
131                }
132            })?;
133            prepare_delta(
134                &options,
135                &manifest,
136                platform_release.full.clone(),
137                artifact,
138                &platform,
139                current_path,
140                &cache_dir,
141                &mut on_event,
142            )
143            .await
144        }
145        SelectedArtifact::Full { artifact } => {
146            prepare_full(
147                &options,
148                &manifest,
149                artifact,
150                &platform,
151                &cache_dir,
152                &mut on_event,
153            )
154            .await
155        }
156    }
157}
158
159pub async fn prepare_delta_update<F>(
160    options: PrepareUpdateOptions,
161    mut on_event: F,
162) -> Result<PreparedUpdate>
163where
164    F: FnMut(DownloadEvent),
165{
166    let platform = options.platform.clone().unwrap_or_else(default_platform);
167    let manifest = load_update_manifest(
168        &options.manifest_url,
169        &options.headers,
170        options.timeout_secs,
171        options.signature_public_key.as_deref(),
172        options.require_signature,
173        options.expected_version.as_deref(),
174    )
175    .await?;
176    let platform_release = manifest.platform(&platform)?;
177    let current_path =
178        options
179            .current_artifact_path
180            .as_ref()
181            .ok_or_else(|| Error::NoMatchingArtifact {
182                platform: platform.clone(),
183                current_version: options.current_version.clone(),
184                current_sha256: None,
185            })?;
186    let current_digest = sha256_file(current_path)?;
187    let selected = manifest.select_artifact(
188        &platform,
189        options.current_version.as_deref(),
190        Some(current_digest.sha256.as_str()),
191    )?;
192    let cache_dir = options.cache_dir.clone().unwrap_or_else(default_cache_dir);
193
194    match selected {
195        SelectedArtifact::Delta { artifact } => {
196            prepare_delta(
197                &options,
198                &manifest,
199                platform_release.full.clone(),
200                artifact,
201                &platform,
202                current_path,
203                &cache_dir,
204                &mut on_event,
205            )
206            .await
207        }
208        SelectedArtifact::Full { .. } => Err(Error::NoMatchingArtifact {
209            platform,
210            current_version: options.current_version.clone(),
211            current_sha256: Some(current_digest.sha256),
212        }),
213    }
214}
215
216async fn prepare_delta<F>(
217    options: &PrepareUpdateOptions,
218    manifest: &UpdateManifest,
219    full_artifact: Artifact,
220    delta: DeltaArtifact,
221    platform: &str,
222    current_path: &Path,
223    cache_dir: &Path,
224    on_event: &mut F,
225) -> Result<PreparedUpdate>
226where
227    F: FnMut(DownloadEvent),
228{
229    if delta.algorithm != DEFAULT_ALGORITHM {
230        return Err(Error::UnsupportedAlgorithm(delta.algorithm));
231    }
232
233    let patch_path = options.patch_path.clone().unwrap_or_else(|| {
234        cache_dir.join(url_file_name(&delta.url).unwrap_or_else(|| {
235            format!(
236                "{}-to-{}.hpatch",
237                delta
238                    .from_version
239                    .clone()
240                    .unwrap_or_else(|| "current".to_string()),
241                manifest.version
242            )
243        }))
244    });
245    let patch_url = resolve_artifact_url(&options.manifest_url, &delta.url);
246    let stats = download_to_file(
247        &patch_url,
248        &patch_path,
249        &options.headers,
250        options.timeout_secs,
251        on_event,
252    )
253    .await?;
254    verify_size(&patch_path, delta.size, stats.bytes_written)?;
255    verify_sha256(&patch_path, &delta.sha256)?;
256
257    let output_path = options.output_path.clone().unwrap_or_else(|| {
258        cache_dir.join(
259            url_file_name(&full_artifact.url)
260                .unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
261        )
262    });
263    let expected = delta
264        .target_sha256
265        .clone()
266        .unwrap_or_else(|| full_artifact.sha256.clone());
267
268    let apply = apply_patch(&ApplyPatchOptions {
269        old_path: current_path.to_path_buf(),
270        patch_path: patch_path.clone(),
271        output_path: output_path.clone(),
272        hpatchz_path: options.hpatchz_path.clone(),
273        force: options.force,
274        expected_sha256: Some(expected),
275        cache_size: None,
276        parallel_threads: Some(4),
277        verify_checksums: true,
278    })?;
279    if let Some(target_size) = delta.target_size {
280        verify_size(&apply.output.path, target_size, apply.output.size)?;
281    }
282
283    Ok(PreparedUpdate {
284        version: manifest.version.clone(),
285        platform: platform.to_string(),
286        kind: PreparedUpdateKind::Delta,
287        artifact_path: output_path,
288        artifact: apply.output,
289        patch_path: Some(patch_path),
290        bytes_downloaded: stats.bytes_written,
291        manifest: manifest.clone(),
292    })
293}
294
295async fn prepare_full<F>(
296    options: &PrepareUpdateOptions,
297    manifest: &UpdateManifest,
298    artifact: Artifact,
299    platform: &str,
300    cache_dir: &Path,
301    on_event: &mut F,
302) -> Result<PreparedUpdate>
303where
304    F: FnMut(DownloadEvent),
305{
306    let output_path = options.output_path.clone().unwrap_or_else(|| {
307        cache_dir.join(
308            url_file_name(&artifact.url)
309                .unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
310        )
311    });
312    let artifact_url = resolve_artifact_url(&options.manifest_url, &artifact.url);
313    let stats = download_to_file(
314        &artifact_url,
315        &output_path,
316        &options.headers,
317        options.timeout_secs,
318        on_event,
319    )
320    .await?;
321    verify_size(&output_path, artifact.size, stats.bytes_written)?;
322    let digest = verify_sha256(&output_path, &artifact.sha256)?;
323
324    Ok(PreparedUpdate {
325        version: manifest.version.clone(),
326        platform: platform.to_string(),
327        kind: PreparedUpdateKind::Full,
328        artifact_path: output_path,
329        artifact: digest,
330        patch_path: None,
331        bytes_downloaded: stats.bytes_written,
332        manifest: manifest.clone(),
333    })
334}
335
336fn resolve_artifact_url(manifest_url: &str, artifact_url: &str) -> String {
337    if Url::parse(artifact_url).is_ok() || Path::new(artifact_url).is_absolute() {
338        return artifact_url.to_string();
339    }
340
341    if let Ok(base_url) = Url::parse(manifest_url) {
342        if let Ok(joined) = base_url.join(artifact_url) {
343            return joined.to_string();
344        }
345    }
346
347    let base = Path::new(manifest_url)
348        .parent()
349        .unwrap_or_else(|| Path::new("."));
350    base.join(artifact_url).to_string_lossy().to_string()
351}
352
353fn url_file_name(url_or_path: &str) -> Option<String> {
354    if let Ok(url) = Url::parse(url_or_path) {
355        return url
356            .path_segments()
357            .and_then(|mut segments| segments.next_back())
358            .filter(|name| !name.is_empty())
359            .map(ToOwned::to_owned);
360    }
361    Path::new(url_or_path)
362        .file_name()
363        .map(|name| name.to_string_lossy().to_string())
364}
365
366fn default_cache_dir() -> PathBuf {
367    let millis = SystemTime::now()
368        .duration_since(UNIX_EPOCH)
369        .map(|duration| duration.as_millis())
370        .unwrap_or_default();
371    std::env::temp_dir()
372        .join("tauri-hdiff-update")
373        .join(millis.to_string())
374}
375
376fn verify_size(path: &Path, expected: u64, actual: u64) -> Result<()> {
377    if expected != actual {
378        return Err(Error::SizeMismatch {
379            path: path.to_path_buf(),
380            expected,
381            actual,
382        });
383    }
384    Ok(())
385}
386
387fn default_true() -> bool {
388    true
389}