1use crate::error::{Error, Result};
2use crate::manifest::{HotswapManifest, HotswapMeta};
3use crate::resolver::{CheckContext, HotswapResolver};
4use flate2::read::GzDecoder;
5use minisign_verify::{PublicKey, Signature};
6use semver::Version;
7use serde::Serialize;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use tar::Archive;
11use tauri::{Emitter, Runtime};
12
13pub const DEFAULT_MAX_BUNDLE_SIZE: u64 = 512 * 1024 * 1024;
15
16pub const DEFAULT_MAX_RETRIES: u32 = 3;
18
19#[derive(Debug, Clone, Serialize)]
21#[non_exhaustive]
22pub struct DownloadProgress {
23 pub downloaded: u64,
25 pub total: Option<u64>,
27}
28
29#[derive(Debug, Clone, Serialize)]
31#[non_exhaustive]
32pub struct LifecycleEvent {
33 pub event: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub version: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub sequence: Option<u64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub error: Option<String>,
44}
45
46pub(crate) fn emit_lifecycle<R: Runtime>(
47 app: Option<&tauri::AppHandle<R>>,
48 event: &str,
49 version: Option<&str>,
50 sequence: Option<u64>,
51 error: Option<&str>,
52) {
53 if let Some(app) = app {
54 let _ = app.emit(
55 "hotswap://lifecycle",
56 LifecycleEvent {
57 event: event.into(),
58 version: version.map(|s| s.to_string()),
59 sequence,
60 error: error.map(|s| s.to_string()),
61 },
62 );
63 }
64}
65
66pub(crate) async fn check_update<R: Runtime>(
69 resolver: &dyn HotswapResolver,
70 ctx: &CheckContext,
71 app: Option<&tauri::AppHandle<R>>,
72) -> Result<Option<HotswapManifest>> {
73 emit_lifecycle(app, "check-start", None, None, None);
74
75 let result = resolver.check(ctx).await;
76
77 let manifest = match result {
78 Ok(Some(m)) => m,
79 Ok(None) => {
80 emit_lifecycle(app, "check-complete", None, None, None);
81 return Ok(None);
82 }
83 Err(e) => {
84 emit_lifecycle(app, "check-error", None, None, Some(&e.to_string()));
85 return Err(e);
86 }
87 };
88
89 let required = Version::parse(&manifest.min_binary_version)
91 .map_err(|e| Error::Version(format!("invalid min_binary_version: {}", e)))?;
92 let current_bin = Version::parse(&ctx.binary_version)
93 .map_err(|e| Error::Version(format!("invalid binary version: {}", e)))?;
94
95 if current_bin < required {
96 log::warn!(
97 "[hotswap] Seq {} requires binary >= {}, current binary is {}. Skipping.",
98 manifest.sequence,
99 manifest.min_binary_version,
100 ctx.binary_version
101 );
102 emit_lifecycle(app, "check-complete", None, None, None);
103 return Ok(None);
104 }
105
106 if manifest.sequence <= ctx.current_sequence {
107 log::info!(
108 "[hotswap] Manifest sequence {} is not newer than current {}",
109 manifest.sequence,
110 ctx.current_sequence
111 );
112 emit_lifecycle(app, "check-complete", None, None, None);
113 return Ok(None);
114 }
115
116 log::info!(
117 "[hotswap] Update available: seq {} -> {} (v{}, requires binary >= {})",
118 ctx.current_sequence,
119 manifest.sequence,
120 manifest.version,
121 manifest.min_binary_version
122 );
123
124 emit_lifecycle(
125 app,
126 "check-complete",
127 Some(&manifest.version),
128 Some(manifest.sequence),
129 None,
130 );
131 Ok(Some(manifest))
132}
133
134pub(crate) struct DownloadOptions<'a> {
136 pub pubkey: &'a str,
137 pub base_dir: &'a Path,
138 pub max_bundle_size: u64,
139 pub require_https: bool,
140 pub max_retries: u32,
141 pub client: &'a reqwest::Client,
142 pub headers: &'a HashMap<String, String>,
143}
144
145pub(crate) async fn download_and_extract<R: Runtime>(
147 manifest: &HotswapManifest,
148 opts: &DownloadOptions<'_>,
149 app: Option<&tauri::AppHandle<R>>,
150) -> Result<PathBuf> {
151 let base_dir = opts.base_dir;
152 let pubkey = opts.pubkey;
153
154 if opts.require_https && !manifest.url.starts_with("https://") {
155 return Err(Error::InsecureUrl(manifest.url.clone()));
156 }
157
158 let version_dir = base_dir.join(format!("seq-{}", manifest.sequence));
159
160 emit_lifecycle(
161 app,
162 "download-start",
163 Some(&manifest.version),
164 Some(manifest.sequence),
165 None,
166 );
167
168 let buf = download_with_retry(
169 &manifest.url,
170 opts.max_bundle_size,
171 opts.max_retries,
172 opts.client,
173 opts.headers,
174 app,
175 )
176 .await
177 .inspect_err(|e| {
178 emit_lifecycle(
179 app,
180 "download-error",
181 Some(&manifest.version),
182 Some(manifest.sequence),
183 Some(&e.to_string()),
184 );
185 })?;
186
187 log::info!(
188 "[hotswap] Downloaded {} bytes, verifying signature...",
189 buf.len()
190 );
191
192 verify_signature(&buf, &manifest.signature, pubkey)?;
193
194 emit_lifecycle(
195 app,
196 "download-complete",
197 Some(&manifest.version),
198 Some(manifest.sequence),
199 None,
200 );
201
202 log::info!(
203 "[hotswap] Signature verified, extracting to: {}",
204 version_dir.display()
205 );
206
207 let tmp_dir = base_dir.join(format!(".tmp-seq-{}", manifest.sequence));
209 if tmp_dir.exists() {
210 std::fs::remove_dir_all(&tmp_dir)?;
211 }
212 std::fs::create_dir_all(&tmp_dir)?;
213
214 let extract_result = {
215 let url_lower = manifest.url.to_lowercase();
216 if url_lower.ends_with(".zip") {
217 #[cfg(feature = "zip")]
218 {
219 extract_zip(&buf, &tmp_dir)
220 }
221 #[cfg(not(feature = "zip"))]
222 {
223 Err(Error::Extraction(
224 "bundle is a .zip but the 'zip' feature is not enabled — \
225 add features = [\"zip\"] to your Cargo.toml"
226 .into(),
227 ))
228 }
229 } else {
230 extract_tar_gz(&buf, &tmp_dir)
231 }
232 };
233
234 if let Err(e) = extract_result {
235 let _ = std::fs::remove_dir_all(&tmp_dir);
237 return Err(e);
238 }
239
240 write_meta_file(
242 &tmp_dir,
243 &HotswapMeta {
244 version: manifest.version.clone(),
245 sequence: manifest.sequence,
246 min_binary_version: manifest.min_binary_version.clone(),
247 confirmed: false,
248 unconfirmed_launch_count: 0,
249 },
250 )?;
251
252 if version_dir.exists() {
254 std::fs::remove_dir_all(&version_dir)?;
255 }
256 std::fs::rename(&tmp_dir, &version_dir)?;
257
258 log::info!("[hotswap] Extraction complete: {}", version_dir.display());
259
260 Ok(version_dir)
261}
262
263async fn download_with_retry<R: Runtime>(
265 url: &str,
266 max_bundle_size: u64,
267 max_retries: u32,
268 client: &reqwest::Client,
269 headers: &HashMap<String, String>,
270 app: Option<&tauri::AppHandle<R>>,
271) -> Result<Vec<u8>> {
272 let mut last_error = None;
273
274 for attempt in 0..=max_retries {
275 if attempt > 0 {
276 let delay = std::time::Duration::from_millis(1000 * (1 << (attempt - 1).min(4)));
277 log::info!(
278 "[hotswap] Retry {}/{} after {:?}",
279 attempt,
280 max_retries,
281 delay
282 );
283 tokio::time::sleep(delay).await;
284 }
285
286 match download_once(url, max_bundle_size, client, headers, app).await {
287 Ok(buf) => return Ok(buf),
288 Err(e) => {
289 log::warn!("[hotswap] Download attempt {} failed: {}", attempt + 1, e);
290 last_error = Some(e);
291 }
292 }
293 }
294
295 Err(last_error.unwrap_or_else(|| Error::Network("download failed".into())))
296}
297
298async fn download_once<R: Runtime>(
300 url: &str,
301 max_bundle_size: u64,
302 client: &reqwest::Client,
303 headers: &HashMap<String, String>,
304 app: Option<&tauri::AppHandle<R>>,
305) -> Result<Vec<u8>> {
306 log::info!("[hotswap] Downloading bundle from: {}", url);
307
308 let mut req = client.get(url).timeout(std::time::Duration::from_secs(300));
309
310 for (key, value) in headers {
311 req = req.header(key.as_str(), value.as_str());
312 }
313
314 let response = req
315 .send()
316 .await
317 .map_err(|e| Error::Network(e.to_string()))?;
318
319 if !response.status().is_success() {
320 return Err(Error::Http {
321 status: response.status().as_u16(),
322 message: "bundle download failed".into(),
323 });
324 }
325
326 if let Some(content_length) = response.content_length() {
327 if content_length > max_bundle_size {
328 return Err(Error::BundleTooLarge {
329 size: content_length,
330 limit: max_bundle_size,
331 });
332 }
333 }
334
335 let total = response.content_length();
336 let mut downloaded: u64 = 0;
337 let initial_capacity = total.unwrap_or(1024 * 1024).min(max_bundle_size) as usize;
338 let mut buf = Vec::with_capacity(initial_capacity);
339
340 let mut stream = response;
341 loop {
342 let chunk = stream
343 .chunk()
344 .await
345 .map_err(|e| Error::Network(e.to_string()))?;
346 match chunk {
347 Some(data) => {
348 downloaded += data.len() as u64;
349 if downloaded > max_bundle_size {
350 return Err(Error::BundleTooLarge {
351 size: downloaded,
352 limit: max_bundle_size,
353 });
354 }
355 buf.extend_from_slice(&data);
356 if let Some(app) = app {
357 let _ = app.emit(
358 "hotswap://download-progress",
359 DownloadProgress { downloaded, total },
360 );
361 }
362 }
363 None => break,
364 }
365 }
366
367 Ok(buf)
368}
369
370fn write_meta_file(version_dir: &Path, meta: &HotswapMeta) -> Result<()> {
372 let meta_path = version_dir.join("hotswap-meta.json");
373 let meta_json =
374 serde_json::to_string_pretty(meta).map_err(|e| Error::Serialization(e.to_string()))?;
375
376 std::fs::write(&meta_path, &meta_json)?;
377
378 #[cfg(unix)]
379 {
380 use std::os::unix::fs::PermissionsExt;
381 let _ = std::fs::set_permissions(&meta_path, std::fs::Permissions::from_mode(0o600));
382 }
383
384 Ok(())
385}
386
387fn validate_entry_path(entry_path: &Path, dest: &Path) -> Result<PathBuf> {
389 let path_str = entry_path.to_string_lossy();
390
391 if entry_path.is_absolute() {
392 return Err(Error::Extraction(format!(
393 "absolute path in archive: {}",
394 path_str
395 )));
396 }
397
398 for component in entry_path.components() {
399 match component {
400 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
401 _ => {
402 return Err(Error::Extraction(format!(
403 "unsafe path component in archive: {}",
404 path_str
405 )));
406 }
407 }
408 }
409
410 let target = dest.join(entry_path);
411 if !target.starts_with(dest) {
412 return Err(Error::Extraction(format!(
413 "path escapes destination: {}",
414 path_str
415 )));
416 }
417
418 Ok(target)
419}
420
421fn extract_tar_gz(bytes: &[u8], dest: &Path) -> Result<()> {
422 let decoder = GzDecoder::new(bytes);
423 let mut archive = Archive::new(decoder);
424
425 for entry in archive
426 .entries()
427 .map_err(|e| Error::Extraction(e.to_string()))?
428 {
429 let mut entry = entry.map_err(|e| Error::Extraction(e.to_string()))?;
430 let path = entry
431 .path()
432 .map_err(|e| Error::Extraction(e.to_string()))?
433 .to_path_buf();
434
435 let target = validate_entry_path(&path, dest)?;
436
437 if let Some(parent) = target.parent() {
438 std::fs::create_dir_all(parent)?;
439 }
440
441 if entry.header().entry_type().is_file() {
442 let mut file = std::fs::File::create(&target)?;
443 std::io::copy(&mut entry, &mut file)?;
444 }
445 }
446
447 Ok(())
448}
449
450#[cfg(feature = "zip")]
451fn extract_zip(bytes: &[u8], dest: &Path) -> Result<()> {
452 let cursor = std::io::Cursor::new(bytes);
453 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| Error::Extraction(e.to_string()))?;
454
455 for i in 0..archive.len() {
456 let mut file = archive
457 .by_index(i)
458 .map_err(|e| Error::Extraction(e.to_string()))?;
459
460 let entry_path = PathBuf::from(file.name());
461 let target = validate_entry_path(&entry_path, dest)?;
462
463 if file.is_dir() {
464 std::fs::create_dir_all(&target)?;
465 } else {
466 if let Some(parent) = target.parent() {
467 std::fs::create_dir_all(parent)?;
468 }
469 let mut outfile = std::fs::File::create(&target)?;
470 std::io::copy(&mut file, &mut outfile)?;
471 }
472 }
473
474 Ok(())
475}
476
477fn verify_signature(data: &[u8], signature_str: &str, pubkey_str: &str) -> Result<()> {
478 let pk = PublicKey::from_base64(pubkey_str)
479 .map_err(|e| Error::Signature(format!("invalid public key: {}", e)))?;
480
481 let sig_text = if signature_str.starts_with("untrusted comment:") {
482 signature_str.to_string()
483 } else {
484 let decoded = base64_decode(signature_str)
485 .map_err(|e| Error::Signature(format!("base64 decode failed: {}", e)))?;
486 String::from_utf8(decoded)
487 .map_err(|e| Error::Signature(format!("signature is not valid UTF-8: {}", e)))?
488 };
489
490 let sig = Signature::decode(&sig_text)
491 .map_err(|e| Error::Signature(format!("invalid signature format: {}", e)))?;
492
493 pk.verify(data, &sig, false)
494 .map_err(|e| Error::Signature(e.to_string()))?;
495
496 Ok(())
497}
498
499fn base64_decode(input: &str) -> std::result::Result<Vec<u8>, String> {
500 use base64::Engine;
501 base64::engine::general_purpose::STANDARD
502 .decode(input.trim())
503 .map_err(|e| e.to_string())
504}
505
506pub(crate) fn activate_version(base_dir: &Path, version_dir: &Path) -> Result<()> {
508 let current_link = base_dir.join("current");
509 let tmp_link = base_dir.join("current.tmp");
510
511 let dir_name = version_dir
512 .file_name()
513 .ok_or_else(|| Error::Extraction("invalid version dir".into()))?
514 .to_string_lossy();
515
516 std::fs::write(&tmp_link, dir_name.as_bytes())?;
517 std::fs::rename(&tmp_link, ¤t_link)?;
518
519 log::info!("[hotswap] Activated version: {}", dir_name);
520 Ok(())
521}
522
523fn parse_seq(name: &str) -> Option<u64> {
524 name.strip_prefix("seq-")?.parse::<u64>().ok()
525}
526
527fn sorted_version_dirs(base_dir: &Path) -> Vec<(u64, std::fs::DirEntry)> {
528 let mut versions: Vec<_> = std::fs::read_dir(base_dir)
529 .into_iter()
530 .flatten()
531 .filter_map(|e| e.ok())
532 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
533 .filter_map(|e| {
534 let name = e.file_name().to_string_lossy().to_string();
535 let seq = parse_seq(&name)?;
536 Some((seq, e))
537 })
538 .collect();
539
540 versions.sort_by(|a, b| b.0.cmp(&a.0));
541 versions
542}
543
544fn validate_pointer(value: &str) -> Option<&str> {
545 let trimmed = value.trim();
546 if trimmed.is_empty() {
547 return None;
548 }
549 if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
550 log::warn!("[hotswap] Rejecting unsafe pointer value: {:?}", trimmed);
551 return None;
552 }
553 if parse_seq(trimmed).is_none() {
554 log::warn!(
555 "[hotswap] Rejecting pointer with unexpected format: {:?}",
556 trimmed
557 );
558 return None;
559 }
560 Some(trimmed)
561}
562
563pub(crate) fn resolve_current_dir(base_dir: &Path) -> Option<PathBuf> {
564 let current_pointer = base_dir.join("current");
565 if !current_pointer.exists() {
566 return None;
567 }
568
569 let raw = std::fs::read_to_string(¤t_pointer).ok()?;
570 let version_name = validate_pointer(&raw)?;
571
572 let version_dir = base_dir.join(version_name);
573
574 if !version_dir.starts_with(base_dir) {
575 log::warn!(
576 "[hotswap] Pointer resolved outside base dir: {}",
577 version_dir.display()
578 );
579 return None;
580 }
581
582 if version_dir.is_dir() {
583 Some(version_dir)
584 } else {
585 log::warn!(
586 "[hotswap] Current pointer references missing dir: {}",
587 version_dir.display()
588 );
589 None
590 }
591}
592
593pub(crate) fn read_meta(version_dir: &Path) -> Option<HotswapMeta> {
594 let meta_path = version_dir.join("hotswap-meta.json");
595 let content = std::fs::read_to_string(&meta_path).ok()?;
596 serde_json::from_str(&content).ok()
597}
598
599pub(crate) fn check_compatibility(
600 base_dir: &Path,
601 binary_version: &str,
602 cache_policy: &dyn crate::policy::BinaryCachePolicy,
603 confirmation_policy: &dyn crate::policy::ConfirmationPolicy,
604 rollback_policy: &dyn crate::policy::RollbackPolicy,
605) -> Option<PathBuf> {
606 let version_dir = resolve_current_dir(base_dir)?;
607 let mut meta = match read_meta(&version_dir) {
608 Some(m) => m,
609 None => {
610 log::warn!(
611 "[hotswap] Failed to read metadata from {}. Falling back to embedded.",
612 version_dir.display()
613 );
614 return None;
615 }
616 };
617
618 let required = match Version::parse(&meta.min_binary_version) {
619 Ok(v) => v,
620 Err(e) => {
621 log::warn!(
622 "[hotswap] Invalid semver in min_binary_version '{}': {}. Falling back to embedded.",
623 meta.min_binary_version,
624 e
625 );
626 return None;
627 }
628 };
629 let current = match Version::parse(binary_version) {
630 Ok(v) => v,
631 Err(e) => {
632 log::warn!(
633 "[hotswap] Invalid semver in binary_version '{}': {}. Falling back to embedded.",
634 binary_version,
635 e
636 );
637 return None;
638 }
639 };
640
641 if current < required {
643 log::warn!(
644 "[hotswap] Cached v{} requires binary >= {}, current is {}. Falling back to embedded.",
645 meta.version,
646 meta.min_binary_version,
647 binary_version
648 );
649 return None;
650 }
651
652 if cache_policy.should_discard(¤t, &meta, None) {
653 log::info!(
654 "[hotswap] Binary cache policy discards cached v{} (binary={}, min={}).",
655 meta.version,
656 binary_version,
657 meta.min_binary_version
658 );
659 if let Err(e) = std::fs::remove_file(base_dir.join("current")) {
660 log::warn!("[hotswap] Failed to remove current pointer: {}", e);
661 }
662 if let Err(e) = std::fs::remove_dir_all(&version_dir) {
663 log::warn!(
664 "[hotswap] Failed to remove version dir {}: {}",
665 version_dir.display(),
666 e
667 );
668 }
669 return None;
670 }
671
672 if !meta.confirmed {
673 use crate::policy::ConfirmationDecision;
674 match confirmation_policy.on_startup_unconfirmed(&meta) {
675 ConfirmationDecision::KeepForNow => {
676 log::info!(
677 "[hotswap] v{} unconfirmed (launch {}), keeping for now.",
678 meta.version,
679 meta.unconfirmed_launch_count + 1
680 );
681 meta.unconfirmed_launch_count += 1;
682 if let Err(e) = write_meta_file(&version_dir, &meta) {
683 log::error!(
684 "[hotswap] Failed to persist unconfirmed launch count for {}: {}",
685 version_dir.display(),
686 e
687 );
688 }
689 return Some(version_dir);
690 }
691 ConfirmationDecision::RollbackNow => {
692 log::warn!(
693 "[hotswap] v{} was not confirmed (notifyReady not called). Rolling back.",
694 meta.version
695 );
696 rollback(base_dir, rollback_policy);
697 return resolve_current_dir(base_dir).and_then(|dir| {
698 let prev_meta = read_meta(&dir)?;
699 if prev_meta.confirmed {
700 Some(dir)
701 } else {
702 None
703 }
704 });
705 }
706 }
707 }
708
709 Some(version_dir)
710}
711
712pub(crate) fn rollback(
713 base_dir: &Path,
714 rollback_policy: &dyn crate::policy::RollbackPolicy,
715) -> Option<String> {
716 let current_pointer = base_dir.join("current");
717 let raw = std::fs::read_to_string(¤t_pointer).ok()?;
718 let current_version = validate_pointer(&raw)?.to_string();
719 let current_seq = parse_seq(¤t_version);
720
721 if let Err(e) = std::fs::remove_file(¤t_pointer) {
722 log::warn!(
723 "[hotswap] Failed to remove current pointer during rollback: {}",
724 e
725 );
726 }
727
728 let broken_dir = base_dir.join(¤t_version);
729 if broken_dir.exists() {
730 if let Err(e) = std::fs::remove_dir_all(&broken_dir) {
731 log::warn!(
732 "[hotswap] Failed to remove broken version dir {}: {}",
733 broken_dir.display(),
734 e
735 );
736 }
737 }
738
739 let versions = sorted_version_dirs(base_dir);
741 let confirmed_candidates: Vec<HotswapMeta> = versions
742 .iter()
743 .filter_map(|(_, entry)| {
744 let dir = entry.path();
745 let meta = read_meta(&dir)?;
746 if meta.confirmed {
747 Some(meta)
748 } else {
749 None
750 }
751 })
752 .collect();
753
754 if let Some(target_seq) = rollback_policy.select_target(current_seq, &confirmed_candidates) {
755 let target_name = format!("seq-{}", target_seq);
756 let target_dir = base_dir.join(&target_name);
757 if let Some(meta) = read_meta(&target_dir) {
758 let tmp_link = base_dir.join("current.tmp");
759 if let Err(e) = std::fs::write(&tmp_link, &target_name) {
760 log::error!(
761 "[hotswap] Failed to write rollback pointer for {}: {}",
762 target_name,
763 e
764 );
765 return None;
766 }
767 if let Err(e) = std::fs::rename(&tmp_link, ¤t_pointer) {
768 log::error!(
769 "[hotswap] Failed to activate rollback pointer for {}: {}",
770 target_name,
771 e
772 );
773 return None;
774 }
775 log::info!("[hotswap] Rolled back to {}", target_name);
776 return Some(meta.version);
777 }
778 }
779
780 log::info!("[hotswap] Rolled back to embedded assets (no valid previous version)");
781 None
782}
783
784pub(crate) fn cleanup_old_versions(
785 base_dir: &Path,
786 retention_policy: &dyn crate::policy::RetentionPolicy,
787 rollback_policy: &dyn crate::policy::RollbackPolicy,
788) {
789 let current_seq = std::fs::read_to_string(base_dir.join("current"))
790 .ok()
791 .and_then(|raw| parse_seq(raw.trim()));
792
793 let versions = sorted_version_dirs(base_dir);
794
795 let available: Vec<HotswapMeta> = versions
797 .iter()
798 .filter_map(|(_, entry)| read_meta(&entry.path()))
799 .collect();
800
801 let confirmed_candidates: Vec<HotswapMeta> =
803 available.iter().filter(|m| m.confirmed).cloned().collect();
804 let rollback_candidate = rollback_policy.select_target(current_seq, &confirmed_candidates);
805
806 let mut kept =
808 retention_policy.select_kept_sequences(current_seq, rollback_candidate, &available);
809
810 if let Some(seq) = current_seq {
813 kept.insert(seq);
814 }
815 if let Some(seq) = rollback_candidate {
816 kept.insert(seq);
817 }
818
819 for (seq, entry) in &versions {
820 if !kept.contains(seq) {
821 log::info!(
822 "[hotswap] Cleaning up old version: {}",
823 entry.file_name().to_string_lossy()
824 );
825 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
826 log::warn!(
827 "[hotswap] Failed to remove old version {}: {}",
828 entry.file_name().to_string_lossy(),
829 e
830 );
831 }
832 }
833 }
834
835 if let Ok(entries) = std::fs::read_dir(base_dir) {
837 for entry in entries.filter_map(|e| e.ok()) {
838 let name = entry.file_name().to_string_lossy().to_string();
839 if name.starts_with(".tmp-seq-") {
840 log::info!("[hotswap] Cleaning up temp dir: {}", name);
841 if let Err(e) = std::fs::remove_dir_all(entry.path()) {
842 log::warn!("[hotswap] Failed to remove temp dir {}: {}", name, e);
843 }
844 }
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use crate::policy::*;
853 use std::fs;
854 use tempfile::TempDir;
855
856 fn create_version(dir: &Path, version: &str, sequence: u64, min_bin: &str, confirmed: bool) {
857 fs::create_dir_all(dir).unwrap();
858 fs::write(dir.join("index.html"), "<html></html>").unwrap();
859 write_meta_file(
860 dir,
861 &HotswapMeta {
862 version: version.to_string(),
863 sequence,
864 min_binary_version: min_bin.to_string(),
865 confirmed,
866 unconfirmed_launch_count: 0,
867 },
868 )
869 .unwrap();
870 }
871
872 fn set_current(base: &Path, name: &str) {
873 fs::write(base.join("current"), name).unwrap();
874 }
875
876 #[test]
877 fn test_parse_seq_valid() {
878 assert_eq!(parse_seq("seq-0"), Some(0));
879 assert_eq!(parse_seq("seq-42"), Some(42));
880 }
881
882 #[test]
883 fn test_parse_seq_invalid() {
884 assert_eq!(parse_seq("seq-"), None);
885 assert_eq!(parse_seq("seq-abc"), None);
886 assert_eq!(parse_seq(""), None);
887 }
888
889 #[test]
890 fn test_validate_pointer_valid() {
891 assert_eq!(validate_pointer("seq-1"), Some("seq-1"));
892 assert_eq!(validate_pointer(" seq-42 "), Some("seq-42"));
893 }
894
895 #[test]
896 fn test_validate_pointer_rejects_traversal() {
897 assert!(validate_pointer("../etc").is_none());
898 assert!(validate_pointer("seq-1/../../etc").is_none());
899 }
900
901 #[test]
902 fn test_validate_pointer_rejects_bad_format() {
903 assert!(validate_pointer("").is_none());
904 assert!(validate_pointer("not-a-seq").is_none());
905 }
906
907 #[test]
908 fn test_validate_entry_path_ok() {
909 let dest = Path::new("/tmp/extract");
910 let target = validate_entry_path(Path::new("index.html"), dest).unwrap();
911 assert_eq!(target, PathBuf::from("/tmp/extract/index.html"));
912 }
913
914 #[test]
915 fn test_validate_entry_path_rejects_absolute() {
916 assert!(validate_entry_path(Path::new("/etc/passwd"), Path::new("/tmp/extract")).is_err());
917 }
918
919 #[test]
920 fn test_validate_entry_path_rejects_traversal() {
921 let dest = Path::new("/tmp/extract");
922 assert!(validate_entry_path(Path::new("../escape.txt"), dest).is_err());
923 assert!(validate_entry_path(Path::new("foo/../../escape.txt"), dest).is_err());
924 }
925
926 #[test]
927 fn test_sorted_version_dirs_numeric_order() {
928 let tmp = TempDir::new().unwrap();
929 for i in &[2, 10, 1, 3] {
930 fs::create_dir_all(tmp.path().join(format!("seq-{}", i))).unwrap();
931 }
932 fs::create_dir_all(tmp.path().join("other-dir")).unwrap();
933
934 let sorted = sorted_version_dirs(tmp.path());
935 let seqs: Vec<u64> = sorted.iter().map(|(s, _)| *s).collect();
936 assert_eq!(seqs, vec![10, 3, 2, 1]);
937 }
938
939 #[test]
940 fn test_read_meta() {
941 let tmp = TempDir::new().unwrap();
942 let dir = tmp.path().join("seq-1");
943 create_version(&dir, "1.0.0", 1, "0.1.0", true);
944 let meta = read_meta(&dir).unwrap();
945 assert_eq!(meta.version, "1.0.0");
946 assert!(meta.confirmed);
947 }
948
949 #[test]
950 fn test_write_meta_file_permissions() {
951 let tmp = TempDir::new().unwrap();
952 let dir = tmp.path().join("seq-1");
953 fs::create_dir_all(&dir).unwrap();
954 write_meta_file(
955 &dir,
956 &HotswapMeta {
957 version: "1.0.0".into(),
958 sequence: 1,
959 min_binary_version: "1.0.0".into(),
960 confirmed: false,
961 unconfirmed_launch_count: 0,
962 },
963 )
964 .unwrap();
965
966 #[cfg(unix)]
967 {
968 use std::os::unix::fs::PermissionsExt;
969 let perms = fs::metadata(dir.join("hotswap-meta.json"))
970 .unwrap()
971 .permissions();
972 assert_eq!(perms.mode() & 0o777, 0o600);
973 }
974 }
975
976 #[test]
977 fn test_resolve_current_dir() {
978 let tmp = TempDir::new().unwrap();
979 let seq1 = tmp.path().join("seq-1");
980 create_version(&seq1, "1.0.0", 1, "0.1.0", true);
981 set_current(tmp.path(), "seq-1");
982 assert_eq!(resolve_current_dir(tmp.path()), Some(seq1));
983 }
984
985 #[test]
986 fn test_resolve_current_dir_rejects_traversal() {
987 let tmp = TempDir::new().unwrap();
988 fs::write(tmp.path().join("current"), "../escape").unwrap();
989 assert!(resolve_current_dir(tmp.path()).is_none());
990 }
991
992 #[test]
993 fn test_check_compatibility_ok() {
994 let tmp = TempDir::new().unwrap();
995 let seq1 = tmp.path().join("seq-1");
996 create_version(&seq1, "1.0.0-ota.1", 1, "1.0.0", true);
997 set_current(tmp.path(), "seq-1");
998 assert_eq!(
999 check_compatibility(
1000 tmp.path(),
1001 "1.0.0",
1002 &BinaryCachePolicyKind::DiscardOnUpgrade,
1003 &ConfirmationPolicyKind::SingleLaunch,
1004 &RollbackPolicyKind::LatestConfirmed,
1005 ),
1006 Some(seq1)
1007 );
1008 }
1009
1010 #[test]
1011 fn test_check_compatibility_unconfirmed_triggers_rollback() {
1012 let tmp = TempDir::new().unwrap();
1013 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", true);
1014 create_version(&tmp.path().join("seq-2"), "v2", 2, "1.0.0", false);
1015 set_current(tmp.path(), "seq-2");
1016 assert_eq!(
1017 check_compatibility(
1018 tmp.path(),
1019 "1.0.0",
1020 &BinaryCachePolicyKind::DiscardOnUpgrade,
1021 &ConfirmationPolicyKind::SingleLaunch,
1022 &RollbackPolicyKind::LatestConfirmed,
1023 ),
1024 Some(tmp.path().join("seq-1"))
1025 );
1026 }
1027
1028 #[test]
1029 fn test_activate_version() {
1030 let tmp = TempDir::new().unwrap();
1031 let seq1 = tmp.path().join("seq-1");
1032 fs::create_dir_all(&seq1).unwrap();
1033 activate_version(tmp.path(), &seq1).unwrap();
1034 assert_eq!(
1035 fs::read_to_string(tmp.path().join("current")).unwrap(),
1036 "seq-1"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_rollback_to_previous() {
1042 let tmp = TempDir::new().unwrap();
1043 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", true);
1044 create_version(&tmp.path().join("seq-2"), "v2", 2, "1.0.0", true);
1045 set_current(tmp.path(), "seq-2");
1046 assert_eq!(
1047 rollback(tmp.path(), &RollbackPolicyKind::LatestConfirmed),
1048 Some("v1".to_string())
1049 );
1050 assert_eq!(
1051 fs::read_to_string(tmp.path().join("current")).unwrap(),
1052 "seq-1"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_cleanup_old_versions() {
1058 let tmp = TempDir::new().unwrap();
1059 for i in 1..=4 {
1060 create_version(
1061 &tmp.path().join(format!("seq-{}", i)),
1062 &format!("v{}", i),
1063 i,
1064 "1.0.0",
1065 true,
1066 );
1067 }
1068 set_current(tmp.path(), "seq-4");
1069 cleanup_old_versions(
1070 tmp.path(),
1071 &RetentionConfig::default(),
1072 &RollbackPolicyKind::LatestConfirmed,
1073 );
1074 assert!(tmp.path().join("seq-4").exists());
1075 assert!(tmp.path().join("seq-3").exists());
1076 assert!(!tmp.path().join("seq-1").exists());
1077 assert!(!tmp.path().join("seq-2").exists());
1078 }
1079
1080 #[test]
1081 fn test_extract_tar_gz() {
1082 let tmp = TempDir::new().unwrap();
1083 let dest = tmp.path().join("extracted");
1084 fs::create_dir_all(&dest).unwrap();
1085
1086 let buf = Vec::new();
1087 let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
1088 let mut builder = tar::Builder::new(enc);
1089 let data = b"<html></html>";
1090 let mut header = tar::Header::new_gnu();
1091 header.set_size(data.len() as u64);
1092 header.set_mode(0o644);
1093 header.set_cksum();
1094 builder
1095 .append_data(&mut header, "index.html", &data[..])
1096 .unwrap();
1097 let compressed = builder.into_inner().unwrap().finish().unwrap();
1098
1099 extract_tar_gz(&compressed, &dest).unwrap();
1100 assert!(dest.join("index.html").exists());
1101 }
1102
1103 #[test]
1104 fn test_verify_signature_invalid() {
1105 assert!(verify_signature(b"hello", "not-a-sig", "not-a-key").is_err());
1106 }
1107
1108 #[test]
1109 fn test_base64_roundtrip() {
1110 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"hello");
1111 assert_eq!(base64_decode(&encoded).unwrap(), b"hello");
1112 }
1113
1114 #[test]
1115 fn test_cleanup_removes_tmp_dirs() {
1116 let tmp = TempDir::new().unwrap();
1117 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", true);
1118 set_current(tmp.path(), "seq-1");
1119 fs::create_dir_all(tmp.path().join(".tmp-seq-2")).unwrap();
1121 fs::write(tmp.path().join(".tmp-seq-2/index.html"), "partial").unwrap();
1122
1123 cleanup_old_versions(
1124 tmp.path(),
1125 &RetentionConfig::default(),
1126 &RollbackPolicyKind::LatestConfirmed,
1127 );
1128
1129 assert!(!tmp.path().join(".tmp-seq-2").exists());
1130 assert!(tmp.path().join("seq-1").exists());
1131 }
1132
1133 struct MockResolver {
1136 result: std::sync::Mutex<Result<Option<HotswapManifest>>>,
1137 }
1138
1139 impl MockResolver {
1140 fn returning_none() -> Self {
1141 Self {
1142 result: std::sync::Mutex::new(Ok(None)),
1143 }
1144 }
1145
1146 fn returning_manifest(m: HotswapManifest) -> Self {
1147 Self {
1148 result: std::sync::Mutex::new(Ok(Some(m))),
1149 }
1150 }
1151 }
1152
1153 impl crate::resolver::HotswapResolver for MockResolver {
1154 fn check(
1155 &self,
1156 _ctx: &crate::resolver::CheckContext,
1157 ) -> std::pin::Pin<
1158 Box<dyn std::future::Future<Output = Result<Option<HotswapManifest>>> + Send>,
1159 > {
1160 let result = {
1161 let mut guard = self.result.lock().unwrap();
1162 std::mem::replace(&mut *guard, Ok(None))
1163 };
1164 Box::pin(async move { result })
1165 }
1166 }
1167
1168 fn make_check_ctx(
1169 binary_version: &str,
1170 current_sequence: u64,
1171 ) -> crate::resolver::CheckContext {
1172 crate::resolver::CheckContext {
1173 current_sequence,
1174 binary_version: binary_version.to_string(),
1175 platform: "macos",
1176 arch: "aarch64",
1177 channel: None,
1178 headers: HashMap::new(),
1179 endpoint_override: None,
1180 }
1181 }
1182
1183 fn make_manifest(sequence: u64, min_binary_version: &str) -> HotswapManifest {
1184 HotswapManifest {
1185 version: format!("1.0.0-ota.{}", sequence),
1186 sequence,
1187 url: "https://cdn.example.com/bundle.tar.gz".into(),
1188 signature: "sig".into(),
1189 min_binary_version: min_binary_version.into(),
1190 notes: None,
1191 pub_date: None,
1192 mandatory: None,
1193 bundle_size: None,
1194 }
1195 }
1196
1197 #[tokio::test]
1200 async fn test_check_update_resolver_returns_none() {
1201 let resolver = MockResolver::returning_none();
1202 let ctx = make_check_ctx("1.0.0", 5);
1203 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>)
1204 .await
1205 .unwrap();
1206 assert!(result.is_none());
1207 }
1208
1209 #[tokio::test]
1210 async fn test_check_update_sequence_not_newer() {
1211 let resolver = MockResolver::returning_manifest(make_manifest(5, "1.0.0"));
1212 let ctx = make_check_ctx("1.0.0", 5);
1213 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>)
1214 .await
1215 .unwrap();
1216 assert!(result.is_none());
1217 }
1218
1219 #[tokio::test]
1220 async fn test_check_update_sequence_older() {
1221 let resolver = MockResolver::returning_manifest(make_manifest(3, "1.0.0"));
1222 let ctx = make_check_ctx("1.0.0", 5);
1223 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>)
1224 .await
1225 .unwrap();
1226 assert!(result.is_none());
1227 }
1228
1229 #[tokio::test]
1230 async fn test_check_update_binary_incompatible() {
1231 let resolver = MockResolver::returning_manifest(make_manifest(10, "2.0.0"));
1232 let ctx = make_check_ctx("1.0.0", 5);
1233 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>)
1234 .await
1235 .unwrap();
1236 assert!(result.is_none());
1237 }
1238
1239 #[tokio::test]
1240 async fn test_check_update_valid_newer_manifest() {
1241 let resolver = MockResolver::returning_manifest(make_manifest(10, "1.0.0"));
1242 let ctx = make_check_ctx("1.0.0", 5);
1243 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>)
1244 .await
1245 .unwrap();
1246 assert!(result.is_some());
1247 let manifest = result.unwrap();
1248 assert_eq!(manifest.sequence, 10);
1249 }
1250
1251 #[tokio::test]
1252 async fn test_check_update_invalid_semver_in_manifest() {
1253 let resolver = MockResolver::returning_manifest(make_manifest(10, "not-semver"));
1254 let ctx = make_check_ctx("1.0.0", 5);
1255 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>).await;
1256 assert!(matches!(result, Err(Error::Version(_))));
1257 }
1258
1259 #[tokio::test]
1260 async fn test_check_update_invalid_binary_version() {
1261 let resolver = MockResolver::returning_manifest(make_manifest(10, "1.0.0"));
1262 let ctx = make_check_ctx("bad-version", 5);
1263 let result = check_update(&resolver, &ctx, None::<&tauri::AppHandle<tauri::Wry>>).await;
1264 assert!(matches!(result, Err(Error::Version(_))));
1265 }
1266
1267 #[test]
1270 fn test_extract_tar_gz_nested_directories() {
1271 let tmp = TempDir::new().unwrap();
1272 let dest = tmp.path().join("out");
1273 fs::create_dir_all(&dest).unwrap();
1274
1275 let buf = Vec::new();
1276 let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
1277 let mut builder = tar::Builder::new(enc);
1278
1279 let data = b"body { color: red; }";
1280 let mut header = tar::Header::new_gnu();
1281 header.set_size(data.len() as u64);
1282 header.set_mode(0o644);
1283 header.set_cksum();
1284 builder
1285 .append_data(&mut header, "assets/css/style.css", &data[..])
1286 .unwrap();
1287
1288 let compressed = builder.into_inner().unwrap().finish().unwrap();
1289 extract_tar_gz(&compressed, &dest).unwrap();
1290
1291 assert!(dest.join("assets/css/style.css").exists());
1292 assert_eq!(
1293 fs::read_to_string(dest.join("assets/css/style.css")).unwrap(),
1294 "body { color: red; }"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_extract_tar_gz_corrupt_data() {
1300 let tmp = TempDir::new().unwrap();
1301 let dest = tmp.path().join("out");
1302 fs::create_dir_all(&dest).unwrap();
1303 let err = extract_tar_gz(b"not valid gzip", &dest).unwrap_err();
1304 assert!(matches!(err, Error::Extraction(_)));
1305 }
1306
1307 #[test]
1310 fn test_check_compatibility_unconfirmed_no_previous() {
1311 let tmp = TempDir::new().unwrap();
1312 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", false);
1313 set_current(tmp.path(), "seq-1");
1314 assert_eq!(
1315 check_compatibility(
1316 tmp.path(),
1317 "1.0.0",
1318 &BinaryCachePolicyKind::DiscardOnUpgrade,
1319 &ConfirmationPolicyKind::SingleLaunch,
1320 &RollbackPolicyKind::LatestConfirmed,
1321 ),
1322 None
1323 );
1324 }
1325
1326 #[test]
1327 fn test_check_compatibility_binary_downgrade() {
1328 let tmp = TempDir::new().unwrap();
1329 create_version(&tmp.path().join("seq-1"), "v1", 1, "2.0.0", true);
1330 set_current(tmp.path(), "seq-1");
1331 assert_eq!(
1332 check_compatibility(
1333 tmp.path(),
1334 "1.0.0",
1335 &BinaryCachePolicyKind::DiscardOnUpgrade,
1336 &ConfirmationPolicyKind::SingleLaunch,
1337 &RollbackPolicyKind::LatestConfirmed,
1338 ),
1339 None
1340 );
1341 }
1342
1343 #[test]
1344 fn test_check_compatibility_discard_on_upgrade_false() {
1345 let tmp = TempDir::new().unwrap();
1346 let seq1 = tmp.path().join("seq-1");
1347 create_version(&seq1, "v1", 1, "1.0.0", true);
1348 set_current(tmp.path(), "seq-1");
1349 assert_eq!(
1350 check_compatibility(
1351 tmp.path(),
1352 "2.0.0",
1353 &BinaryCachePolicyKind::NeverDiscard,
1354 &ConfirmationPolicyKind::SingleLaunch,
1355 &RollbackPolicyKind::LatestConfirmed,
1356 ),
1357 Some(seq1)
1358 );
1359 }
1360
1361 #[test]
1362 fn test_check_compatibility_discard_on_upgrade_true() {
1363 let tmp = TempDir::new().unwrap();
1364 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", true);
1365 set_current(tmp.path(), "seq-1");
1366 assert_eq!(
1367 check_compatibility(
1368 tmp.path(),
1369 "2.0.0",
1370 &BinaryCachePolicyKind::DiscardOnUpgrade,
1371 &ConfirmationPolicyKind::SingleLaunch,
1372 &RollbackPolicyKind::LatestConfirmed,
1373 ),
1374 None
1375 );
1376 assert!(!tmp.path().join("seq-1").exists());
1377 }
1378
1379 #[test]
1382 fn test_cleanup_only_current_version() {
1383 let tmp = TempDir::new().unwrap();
1384 create_version(&tmp.path().join("seq-5"), "v5", 5, "1.0.0", true);
1385 set_current(tmp.path(), "seq-5");
1386 cleanup_old_versions(
1387 tmp.path(),
1388 &RetentionConfig::default(),
1389 &RollbackPolicyKind::LatestConfirmed,
1390 );
1391 assert!(tmp.path().join("seq-5").exists());
1392 }
1393
1394 #[test]
1395 fn test_cleanup_three_versions_keeps_two() {
1396 let tmp = TempDir::new().unwrap();
1397 create_version(&tmp.path().join("seq-1"), "v1", 1, "1.0.0", true);
1398 create_version(&tmp.path().join("seq-2"), "v2", 2, "1.0.0", true);
1399 create_version(&tmp.path().join("seq-3"), "v3", 3, "1.0.0", true);
1400 set_current(tmp.path(), "seq-3");
1401 cleanup_old_versions(
1402 tmp.path(),
1403 &RetentionConfig::default(),
1404 &RollbackPolicyKind::LatestConfirmed,
1405 );
1406 assert!(tmp.path().join("seq-3").exists());
1407 assert!(tmp.path().join("seq-2").exists());
1408 assert!(!tmp.path().join("seq-1").exists());
1409 }
1410
1411 #[test]
1412 fn test_cleanup_numeric_sorting_large_sequences() {
1413 let tmp = TempDir::new().unwrap();
1414 for i in &[1, 2, 3, 10, 11, 100] {
1415 create_version(
1416 &tmp.path().join(format!("seq-{}", i)),
1417 &format!("v{}", i),
1418 *i,
1419 "1.0.0",
1420 true,
1421 );
1422 }
1423 set_current(tmp.path(), "seq-100");
1424
1425 cleanup_old_versions(
1426 tmp.path(),
1427 &RetentionConfig::default(),
1428 &RollbackPolicyKind::LatestConfirmed,
1429 );
1430
1431 assert!(tmp.path().join("seq-100").exists());
1432 assert!(tmp.path().join("seq-11").exists());
1433 assert!(!tmp.path().join("seq-10").exists());
1434 assert!(!tmp.path().join("seq-3").exists());
1435 assert!(!tmp.path().join("seq-2").exists());
1436 assert!(!tmp.path().join("seq-1").exists());
1437 }
1438}