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 prepare_update<F>(
70    options: PrepareUpdateOptions,
71    mut on_event: F,
72) -> Result<PreparedUpdate>
73where
74    F: FnMut(DownloadEvent),
75{
76    let platform = options.platform.clone().unwrap_or_else(default_platform);
77    let manifest_text = read_url_to_string(
78        &options.manifest_url,
79        &options.headers,
80        options.timeout_secs,
81    )
82    .await?;
83    let manifest: UpdateManifest = serde_json::from_str(&manifest_text)?;
84    match options.signature_public_key.as_deref() {
85        Some(public_key) => verify_manifest_signature(&manifest, public_key)?,
86        None if options.require_signature => return Err(Error::SignaturePublicKeyMissing),
87        None => {}
88    }
89    if let Some(expected_version) = options.expected_version.as_deref() {
90        if manifest.version != expected_version {
91            return Err(Error::UnexpectedVersion {
92                expected: expected_version.to_string(),
93                actual: manifest.version,
94            });
95        }
96    }
97    let platform_release = manifest.platform(&platform)?;
98    let current_digest = match &options.current_artifact_path {
99        Some(path) => Some(sha256_file(path)?),
100        None => None,
101    };
102    let selected = manifest.select_artifact(
103        &platform,
104        options.current_version.as_deref(),
105        current_digest.as_ref().map(|digest| digest.sha256.as_str()),
106    )?;
107    let cache_dir = options.cache_dir.clone().unwrap_or_else(default_cache_dir);
108
109    match selected {
110        SelectedArtifact::Delta { artifact } => {
111            let current_path = options.current_artifact_path.as_ref().ok_or_else(|| {
112                Error::NoMatchingArtifact {
113                    platform: platform.clone(),
114                    current_version: options.current_version.clone(),
115                    current_sha256: None,
116                }
117            })?;
118            prepare_delta(
119                &options,
120                &manifest,
121                platform_release.full.clone(),
122                artifact,
123                &platform,
124                current_path,
125                &cache_dir,
126                &mut on_event,
127            )
128            .await
129        }
130        SelectedArtifact::Full { artifact } => {
131            prepare_full(
132                &options,
133                &manifest,
134                artifact,
135                &platform,
136                &cache_dir,
137                &mut on_event,
138            )
139            .await
140        }
141    }
142}
143
144async fn prepare_delta<F>(
145    options: &PrepareUpdateOptions,
146    manifest: &UpdateManifest,
147    full_artifact: Artifact,
148    delta: DeltaArtifact,
149    platform: &str,
150    current_path: &Path,
151    cache_dir: &Path,
152    on_event: &mut F,
153) -> Result<PreparedUpdate>
154where
155    F: FnMut(DownloadEvent),
156{
157    if delta.algorithm != DEFAULT_ALGORITHM {
158        return Err(Error::UnsupportedAlgorithm(delta.algorithm));
159    }
160
161    let patch_path = options.patch_path.clone().unwrap_or_else(|| {
162        cache_dir.join(url_file_name(&delta.url).unwrap_or_else(|| {
163            format!(
164                "{}-to-{}.hpatch",
165                delta
166                    .from_version
167                    .clone()
168                    .unwrap_or_else(|| "current".to_string()),
169                manifest.version
170            )
171        }))
172    });
173    let patch_url = resolve_artifact_url(&options.manifest_url, &delta.url);
174    let stats = download_to_file(
175        &patch_url,
176        &patch_path,
177        &options.headers,
178        options.timeout_secs,
179        on_event,
180    )
181    .await?;
182    verify_size(&patch_path, delta.size, stats.bytes_written)?;
183    verify_sha256(&patch_path, &delta.sha256)?;
184
185    let output_path = options.output_path.clone().unwrap_or_else(|| {
186        cache_dir.join(
187            url_file_name(&full_artifact.url)
188                .unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
189        )
190    });
191    let expected = delta
192        .target_sha256
193        .clone()
194        .unwrap_or_else(|| full_artifact.sha256.clone());
195
196    let apply = apply_patch(&ApplyPatchOptions {
197        old_path: current_path.to_path_buf(),
198        patch_path: patch_path.clone(),
199        output_path: output_path.clone(),
200        hpatchz_path: options.hpatchz_path.clone(),
201        force: options.force,
202        expected_sha256: Some(expected),
203        cache_size: None,
204        parallel_threads: Some(4),
205        verify_checksums: true,
206    })?;
207    if let Some(target_size) = delta.target_size {
208        verify_size(&apply.output.path, target_size, apply.output.size)?;
209    }
210
211    Ok(PreparedUpdate {
212        version: manifest.version.clone(),
213        platform: platform.to_string(),
214        kind: PreparedUpdateKind::Delta,
215        artifact_path: output_path,
216        artifact: apply.output,
217        patch_path: Some(patch_path),
218        bytes_downloaded: stats.bytes_written,
219        manifest: manifest.clone(),
220    })
221}
222
223async fn prepare_full<F>(
224    options: &PrepareUpdateOptions,
225    manifest: &UpdateManifest,
226    artifact: Artifact,
227    platform: &str,
228    cache_dir: &Path,
229    on_event: &mut F,
230) -> Result<PreparedUpdate>
231where
232    F: FnMut(DownloadEvent),
233{
234    let output_path = options.output_path.clone().unwrap_or_else(|| {
235        cache_dir.join(
236            url_file_name(&artifact.url)
237                .unwrap_or_else(|| format!("{}-{}.installer", manifest.version, platform)),
238        )
239    });
240    let artifact_url = resolve_artifact_url(&options.manifest_url, &artifact.url);
241    let stats = download_to_file(
242        &artifact_url,
243        &output_path,
244        &options.headers,
245        options.timeout_secs,
246        on_event,
247    )
248    .await?;
249    verify_size(&output_path, artifact.size, stats.bytes_written)?;
250    let digest = verify_sha256(&output_path, &artifact.sha256)?;
251
252    Ok(PreparedUpdate {
253        version: manifest.version.clone(),
254        platform: platform.to_string(),
255        kind: PreparedUpdateKind::Full,
256        artifact_path: output_path,
257        artifact: digest,
258        patch_path: None,
259        bytes_downloaded: stats.bytes_written,
260        manifest: manifest.clone(),
261    })
262}
263
264fn resolve_artifact_url(manifest_url: &str, artifact_url: &str) -> String {
265    if Url::parse(artifact_url).is_ok() || Path::new(artifact_url).is_absolute() {
266        return artifact_url.to_string();
267    }
268
269    if let Ok(base_url) = Url::parse(manifest_url) {
270        if let Ok(joined) = base_url.join(artifact_url) {
271            return joined.to_string();
272        }
273    }
274
275    let base = Path::new(manifest_url)
276        .parent()
277        .unwrap_or_else(|| Path::new("."));
278    base.join(artifact_url).to_string_lossy().to_string()
279}
280
281fn url_file_name(url_or_path: &str) -> Option<String> {
282    if let Ok(url) = Url::parse(url_or_path) {
283        return url
284            .path_segments()
285            .and_then(|mut segments| segments.next_back())
286            .filter(|name| !name.is_empty())
287            .map(ToOwned::to_owned);
288    }
289    Path::new(url_or_path)
290        .file_name()
291        .map(|name| name.to_string_lossy().to_string())
292}
293
294fn default_cache_dir() -> PathBuf {
295    let millis = SystemTime::now()
296        .duration_since(UNIX_EPOCH)
297        .map(|duration| duration.as_millis())
298        .unwrap_or_default();
299    std::env::temp_dir()
300        .join("tauri-hdiff-update")
301        .join(millis.to_string())
302}
303
304fn verify_size(path: &Path, expected: u64, actual: u64) -> Result<()> {
305    if expected != actual {
306        return Err(Error::SizeMismatch {
307            path: path.to_path_buf(),
308            expected,
309            actual,
310        });
311    }
312    Ok(())
313}
314
315fn default_true() -> bool {
316    true
317}