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}