Skip to main content

tauri_plugin_hotswap/
updater.rs

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
13/// Default maximum bundle size: 512 MB.
14pub const DEFAULT_MAX_BUNDLE_SIZE: u64 = 512 * 1024 * 1024;
15
16/// Default number of download retry attempts.
17pub const DEFAULT_MAX_RETRIES: u32 = 3;
18
19/// Payload emitted on `hotswap://download-progress` events.
20#[derive(Debug, Clone, Serialize)]
21#[non_exhaustive]
22pub struct DownloadProgress {
23    /// Bytes downloaded so far.
24    pub downloaded: u64,
25    /// Total expected bytes (from Content-Length), if known.
26    pub total: Option<u64>,
27}
28
29/// Lifecycle event payload emitted on `hotswap://lifecycle`.
30#[derive(Debug, Clone, Serialize)]
31#[non_exhaustive]
32pub struct LifecycleEvent {
33    /// Event name (e.g. "check-start", "download-complete", "apply", "rollback").
34    pub event: String,
35    /// Version string, if applicable.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub version: Option<String>,
38    /// Sequence number, if applicable.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub sequence: Option<u64>,
41    /// Error message, if this is an error event.
42    #[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
66/// Check for an available update using the configured resolver,
67/// then validate binary compatibility and sequence.
68pub(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    // Check binary compatibility
90    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
134/// Options for the download operation, bundled to avoid excessive arguments.
135pub(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
145/// Download, verify, and extract a bundle with retry and atomic extraction.
146pub(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    // Atomic extraction: extract to temp dir, then rename
208    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        // Clean up failed extraction
236        let _ = std::fs::remove_dir_all(&tmp_dir);
237        return Err(e);
238    }
239
240    // Write metadata into the temp dir before renaming
241    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    // Atomic rename: tmp dir → final seq dir
253    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
263/// Download with retry and exponential backoff.
264async 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
298/// Single download attempt with streaming progress.
299async 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
370/// Write metadata to a version directory with restrictive file permissions.
371fn 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
387/// Validate that an archive entry path is safe to extract into `dest`.
388fn 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
506/// Activate a downloaded version by atomically updating the `current` pointer.
507pub(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, &current_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(&current_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    // Safety invariant: binary too old → always fall back (not trait-controlled)
642    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(&current, &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(&current_pointer).ok()?;
718    let current_version = validate_pointer(&raw)?.to_string();
719    let current_seq = parse_seq(&current_version);
720
721    if let Err(e) = std::fs::remove_file(&current_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(&current_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    // Collect confirmed candidates from remaining version dirs, sorted desc
740    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, &current_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    // Collect all available version metas (sorted desc by sequence)
796    let available: Vec<HotswapMeta> = versions
797        .iter()
798        .filter_map(|(_, entry)| read_meta(&entry.path()))
799        .collect();
800
801    // Determine rollback candidate
802    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    // Ask retention policy which sequences to keep
807    let mut kept =
808        retention_policy.select_kept_sequences(current_seq, rollback_candidate, &available);
809
810    // Safety floor: always preserve current + rollback candidate,
811    // even if the policy didn't include them.
812    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    // Also clean up any leftover temp extraction dirs
836    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        // Simulate a leftover temp extraction dir
1120        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    // ── Mock resolver for check_update tests ──────────────────────────
1134
1135    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    // ── check_update tests ────────────────────────────────────────────
1198
1199    #[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    // ── extract edge cases ────────────────────────────────────────────
1268
1269    #[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    // ── check_compatibility edge cases ────────────────────────────────
1308
1309    #[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    // ── cleanup edge cases ────────────────────────────────────────────
1380
1381    #[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}