Skip to main content

tauri_plugin_hotswap/
policy.rs

1//! Configurable policy traits for OTA update lifecycle decisions.
2//!
3//! Four traits govern how the plugin handles cached bundles at startup,
4//! on rollback, and during cleanup. Each has built-in enum implementations
5//! configurable via `tauri.conf.json`. Custom implementations can be
6//! injected via [`HotswapBuilder`](crate::HotswapBuilder) using the
7//! `binary_cache_policy()`, `confirmation_policy()`, `rollback_policy()`,
8//! and `retention_policy()` setters, which accept any `impl Policy` type.
9//!
10//! # Safety invariants (not trait-pluggable)
11//!
12//! - Signature verification is always mandatory
13//! - Archive path validation remains strict
14//! - Atomic extraction/activation behavior is fixed
15//! - The "binary too old" safety check (`binary < min_binary_version`) is
16//!   hardcoded outside `BinaryCachePolicy` — no policy can override it
17
18use crate::manifest::HotswapMeta;
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use std::collections::HashSet;
22
23// ─── 1) BinaryCachePolicy ─────────────────────────────────────────────
24
25/// Decides whether a cached OTA bundle should be discarded at startup
26/// based on binary version changes.
27///
28/// The safety check (`current_binary < min_binary_version` → always discard)
29/// is enforced **outside** this trait. This trait only governs the policy
30/// decision for compatible binaries.
31pub trait BinaryCachePolicy: Send + Sync + 'static {
32    /// Return `true` to discard the cached bundle and fall back to embedded assets.
33    ///
34    /// `previous_binary` is `None` until a future release adds persistence
35    /// of the previous binary version.
36    fn should_discard(
37        &self,
38        current_binary: &Version,
39        cached_meta: &HotswapMeta,
40        previous_binary: Option<&Version>,
41    ) -> bool;
42}
43
44/// Built-in binary cache policy variants, selectable via config.
45///
46/// # Config examples
47///
48/// ```json
49/// { "binary_cache_policy": "keep_compatible" }
50/// { "binary_cache_policy": "discard_on_upgrade" }
51/// { "binary_cache_policy": "never_discard" }
52/// ```
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum BinaryCachePolicyKind {
56    /// Keep the cache as long as the current binary satisfies `min_binary_version`.
57    /// Recommended for most apps — opt in via `"binary_cache_policy": "keep_compatible"`.
58    KeepCompatible,
59    /// Discard when `current_binary > min_binary_version`.
60    /// This is the default, preserving pre-0.0.2 semantics.
61    ///
62    /// Note: this detects "binary newer than min", not actual binary *changes*
63    /// (e.g., rebuild at same version, downgrade). True change detection
64    /// would require persisting the previous binary version (deferred).
65    #[default]
66    DiscardOnUpgrade,
67    /// Never discard based on binary version. Only the safety check
68    /// (`binary < min`) still applies.
69    NeverDiscard,
70}
71
72impl BinaryCachePolicy for BinaryCachePolicyKind {
73    fn should_discard(
74        &self,
75        current_binary: &Version,
76        cached_meta: &HotswapMeta,
77        _previous_binary: Option<&Version>,
78    ) -> bool {
79        match self {
80            BinaryCachePolicyKind::KeepCompatible => false,
81            BinaryCachePolicyKind::DiscardOnUpgrade => {
82                if let Ok(required) = Version::parse(&cached_meta.min_binary_version) {
83                    current_binary > &required
84                } else {
85                    false
86                }
87            }
88            BinaryCachePolicyKind::NeverDiscard => false,
89        }
90    }
91}
92
93// ─── 2) ConfirmationPolicy ────────────────────────────────────────────
94
95/// Decides what to do on startup if the current OTA version has not
96/// been confirmed via `notifyReady()`.
97///
98/// The trait takes immutable `&HotswapMeta`. The caller handles counter
99/// mutation (incrementing `unconfirmed_launch_count`, writing to disk).
100pub trait ConfirmationPolicy: Send + Sync + 'static {
101    /// Decide what to do with an unconfirmed OTA version at startup.
102    fn on_startup_unconfirmed(&self, meta: &HotswapMeta) -> ConfirmationDecision;
103}
104
105/// Decision returned by [`ConfirmationPolicy::on_startup_unconfirmed`].
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum ConfirmationDecision {
109    /// Keep the version for now. Caller increments `unconfirmed_launch_count`.
110    KeepForNow,
111    /// Trigger rollback immediately.
112    RollbackNow,
113}
114
115/// Built-in confirmation policy variants.
116///
117/// # Config examples
118///
119/// ```json
120/// { "confirmation_policy": "single_launch" }
121/// { "confirmation_policy": { "grace_period": { "max_unconfirmed_launches": 3 } } }
122/// ```
123///
124/// # Threshold semantics
125///
126/// Rollback when `unconfirmed_launch_count >= max_unconfirmed_launches`.
127/// With `max=1` (default `SingleLaunch`), the first unconfirmed startup
128/// triggers rollback — matching the pre-0.0.2 behavior.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
130#[serde(rename_all = "snake_case")]
131pub enum ConfirmationPolicyKind {
132    /// Rollback on first unconfirmed launch (pre-0.0.2 behavior).
133    #[default]
134    SingleLaunch,
135    /// Allow up to N unconfirmed launches before rollback.
136    GracePeriod {
137        /// Number of unconfirmed launches allowed before rollback.
138        /// `0` is treated the same as `SingleLaunch` (immediate rollback).
139        max_unconfirmed_launches: u32,
140    },
141}
142
143impl ConfirmationPolicy for ConfirmationPolicyKind {
144    fn on_startup_unconfirmed(&self, meta: &HotswapMeta) -> ConfirmationDecision {
145        match self {
146            ConfirmationPolicyKind::SingleLaunch => ConfirmationDecision::RollbackNow,
147            ConfirmationPolicyKind::GracePeriod {
148                max_unconfirmed_launches,
149            } => {
150                if *max_unconfirmed_launches == 0
151                    || meta.unconfirmed_launch_count >= *max_unconfirmed_launches
152                {
153                    ConfirmationDecision::RollbackNow
154                } else {
155                    ConfirmationDecision::KeepForNow
156                }
157            }
158        }
159    }
160}
161
162// ─── 3) RollbackPolicy ───────────────────────────────────────────────
163
164/// Selects a rollback target from available confirmed versions.
165pub trait RollbackPolicy: Send + Sync + 'static {
166    /// Select a rollback target from confirmed candidates sorted descending by sequence.
167    /// Returns `None` to fall back to embedded assets.
168    fn select_target(
169        &self,
170        current_sequence: Option<u64>,
171        candidates_desc: &[HotswapMeta],
172    ) -> Option<u64>;
173}
174
175/// Built-in rollback policy variants.
176///
177/// # Config examples
178///
179/// ```json
180/// { "rollback_policy": "latest_confirmed" }
181/// { "rollback_policy": "embedded_only" }
182/// ```
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum RollbackPolicyKind {
186    /// Roll back to the highest-sequence confirmed version (pre-0.0.2 behavior).
187    #[default]
188    LatestConfirmed,
189    /// Roll back to the confirmed version immediately before current.
190    ImmediatePreviousConfirmed,
191    /// Always fall back to embedded assets.
192    EmbeddedOnly,
193}
194
195impl RollbackPolicy for RollbackPolicyKind {
196    fn select_target(
197        &self,
198        current_sequence: Option<u64>,
199        candidates_desc: &[HotswapMeta],
200    ) -> Option<u64> {
201        match self {
202            RollbackPolicyKind::LatestConfirmed => candidates_desc.first().map(|m| m.sequence),
203            RollbackPolicyKind::ImmediatePreviousConfirmed => {
204                let current = current_sequence?;
205                candidates_desc
206                    .iter()
207                    .find(|m| m.sequence < current)
208                    .map(|m| m.sequence)
209            }
210            RollbackPolicyKind::EmbeddedOnly => None,
211        }
212    }
213}
214
215// ─── 4) RetentionPolicy ──────────────────────────────────────────────
216
217/// Determines which cached versions to keep during cleanup.
218///
219/// The orchestrator enforces a safety floor: current and rollback candidate
220/// are always preserved, even if the trait returns fewer sequences.
221pub trait RetentionPolicy: Send + Sync + 'static {
222    /// Return the set of sequence numbers to keep.
223    /// The orchestrator will additionally preserve current + rollback candidate.
224    fn select_kept_sequences(
225        &self,
226        current_sequence: Option<u64>,
227        rollback_candidate: Option<u64>,
228        available_desc: &[HotswapMeta],
229    ) -> HashSet<u64>;
230}
231
232/// Retention configuration.
233///
234/// `max_retained_versions` is the **total** number of versions kept on disk,
235/// including current and rollback candidate.
236///
237/// | `max_retained_versions` | Versions on disk |
238/// |--------------------------|------------------|
239/// | 2 (default) | current + rollback candidate |
240/// | 3 | current + rollback + 1 older |
241/// | 5 | current + rollback + 3 older |
242///
243/// Values below 2 are clamped to 2.
244///
245/// # Config example
246///
247/// ```json
248/// { "max_retained_versions": 3 }
249/// ```
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct RetentionConfig {
252    /// Total number of versions to keep. Default 2, min 2.
253    #[serde(default = "default_max_retained")]
254    pub max_retained_versions: u32,
255}
256
257fn default_max_retained() -> u32 {
258    2
259}
260
261impl Default for RetentionConfig {
262    fn default() -> Self {
263        Self {
264            max_retained_versions: default_max_retained(),
265        }
266    }
267}
268
269impl RetentionConfig {
270    /// Effective max, clamped to floor of 2.
271    pub fn effective_max(&self) -> u32 {
272        self.max_retained_versions.max(2)
273    }
274}
275
276impl RetentionPolicy for RetentionConfig {
277    fn select_kept_sequences(
278        &self,
279        current_sequence: Option<u64>,
280        rollback_candidate: Option<u64>,
281        available_desc: &[HotswapMeta],
282    ) -> HashSet<u64> {
283        let max = self.effective_max() as usize;
284        let mut kept = HashSet::new();
285
286        // Safety floor: always preserve current + rollback candidate
287        if let Some(seq) = current_sequence {
288            kept.insert(seq);
289        }
290        if let Some(seq) = rollback_candidate {
291            kept.insert(seq);
292        }
293
294        // Fill up to max from highest-sequence versions
295        for meta in available_desc {
296            if kept.len() >= max {
297                break;
298            }
299            kept.insert(meta.sequence);
300        }
301
302        kept
303    }
304}
305
306// ─── Tests ───────────────────────────────────────────────────────────
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::manifest::HotswapMeta;
312
313    fn meta(min_bin: &str, seq: u64, confirmed: bool) -> HotswapMeta {
314        HotswapMeta {
315            version: format!("1.0.0-ota.{}", seq),
316            sequence: seq,
317            min_binary_version: min_bin.into(),
318            confirmed,
319            unconfirmed_launch_count: 0,
320        }
321    }
322
323    fn v(s: &str) -> Version {
324        Version::parse(s).unwrap()
325    }
326
327    // ── BinaryCachePolicy ─────────────────────────────────────────
328
329    #[test]
330    fn keep_compatible_keeps_when_binary_matches() {
331        let p = BinaryCachePolicyKind::KeepCompatible;
332        assert!(!p.should_discard(&v("1.0.0"), &meta("1.0.0", 1, true), None));
333    }
334
335    #[test]
336    fn keep_compatible_keeps_when_binary_newer() {
337        let p = BinaryCachePolicyKind::KeepCompatible;
338        assert!(!p.should_discard(&v("2.0.0"), &meta("1.0.0", 1, true), None));
339    }
340
341    #[test]
342    fn discard_on_upgrade_discards_when_binary_newer() {
343        let p = BinaryCachePolicyKind::DiscardOnUpgrade;
344        assert!(p.should_discard(&v("2.0.0"), &meta("1.0.0", 1, true), None));
345    }
346
347    #[test]
348    fn discard_on_upgrade_keeps_when_binary_matches() {
349        let p = BinaryCachePolicyKind::DiscardOnUpgrade;
350        assert!(!p.should_discard(&v("1.0.0"), &meta("1.0.0", 1, true), None));
351    }
352
353    #[test]
354    fn discard_on_upgrade_keeps_on_invalid_semver() {
355        let p = BinaryCachePolicyKind::DiscardOnUpgrade;
356        assert!(!p.should_discard(&v("2.0.0"), &meta("not-semver", 1, true), None));
357    }
358
359    #[test]
360    fn never_discard_keeps_always() {
361        let p = BinaryCachePolicyKind::NeverDiscard;
362        assert!(!p.should_discard(&v("99.0.0"), &meta("1.0.0", 1, true), None));
363    }
364
365    #[test]
366    fn binary_cache_policy_default_is_discard_on_upgrade() {
367        assert_eq!(
368            BinaryCachePolicyKind::default(),
369            BinaryCachePolicyKind::DiscardOnUpgrade
370        );
371    }
372
373    #[test]
374    fn binary_cache_policy_serde_roundtrip() {
375        for (json, expected) in [
376            ("\"keep_compatible\"", BinaryCachePolicyKind::KeepCompatible),
377            (
378                "\"discard_on_upgrade\"",
379                BinaryCachePolicyKind::DiscardOnUpgrade,
380            ),
381            ("\"never_discard\"", BinaryCachePolicyKind::NeverDiscard),
382        ] {
383            let parsed: BinaryCachePolicyKind = serde_json::from_str(json).unwrap();
384            assert_eq!(parsed, expected);
385            let serialized = serde_json::to_string(&expected).unwrap();
386            let reparsed: BinaryCachePolicyKind = serde_json::from_str(&serialized).unwrap();
387            assert_eq!(reparsed, expected);
388        }
389    }
390
391    // ── ConfirmationPolicy ────────────────────────────────────────
392
393    #[test]
394    fn single_launch_always_rollbacks() {
395        let p = ConfirmationPolicyKind::SingleLaunch;
396        let m = meta("1.0.0", 1, false);
397        assert_eq!(
398            p.on_startup_unconfirmed(&m),
399            ConfirmationDecision::RollbackNow
400        );
401    }
402
403    #[test]
404    fn grace_period_keeps_when_under_threshold() {
405        let p = ConfirmationPolicyKind::GracePeriod {
406            max_unconfirmed_launches: 3,
407        };
408        let mut m = meta("1.0.0", 1, false);
409        m.unconfirmed_launch_count = 0;
410        assert_eq!(
411            p.on_startup_unconfirmed(&m),
412            ConfirmationDecision::KeepForNow
413        );
414
415        m.unconfirmed_launch_count = 2;
416        assert_eq!(
417            p.on_startup_unconfirmed(&m),
418            ConfirmationDecision::KeepForNow
419        );
420    }
421
422    #[test]
423    fn grace_period_rollbacks_at_threshold() {
424        let p = ConfirmationPolicyKind::GracePeriod {
425            max_unconfirmed_launches: 3,
426        };
427        let mut m = meta("1.0.0", 1, false);
428        m.unconfirmed_launch_count = 3;
429        assert_eq!(
430            p.on_startup_unconfirmed(&m),
431            ConfirmationDecision::RollbackNow
432        );
433    }
434
435    #[test]
436    fn grace_period_rollbacks_above_threshold() {
437        let p = ConfirmationPolicyKind::GracePeriod {
438            max_unconfirmed_launches: 3,
439        };
440        let mut m = meta("1.0.0", 1, false);
441        m.unconfirmed_launch_count = 10;
442        assert_eq!(
443            p.on_startup_unconfirmed(&m),
444            ConfirmationDecision::RollbackNow
445        );
446    }
447
448    #[test]
449    fn grace_period_zero_is_immediate_rollback() {
450        let p = ConfirmationPolicyKind::GracePeriod {
451            max_unconfirmed_launches: 0,
452        };
453        let m = meta("1.0.0", 1, false);
454        assert_eq!(
455            p.on_startup_unconfirmed(&m),
456            ConfirmationDecision::RollbackNow
457        );
458    }
459
460    #[test]
461    fn confirmation_policy_default_is_single_launch() {
462        assert_eq!(
463            ConfirmationPolicyKind::default(),
464            ConfirmationPolicyKind::SingleLaunch
465        );
466    }
467
468    #[test]
469    fn confirmation_policy_serde_roundtrip() {
470        let single: ConfirmationPolicyKind = serde_json::from_str("\"single_launch\"").unwrap();
471        assert_eq!(single, ConfirmationPolicyKind::SingleLaunch);
472
473        let grace: ConfirmationPolicyKind =
474            serde_json::from_str(r#"{"grace_period":{"max_unconfirmed_launches":5}}"#).unwrap();
475        assert_eq!(
476            grace,
477            ConfirmationPolicyKind::GracePeriod {
478                max_unconfirmed_launches: 5
479            }
480        );
481    }
482
483    // ── RollbackPolicy ────────────────────────────────────────────
484
485    fn confirmed_candidates() -> Vec<HotswapMeta> {
486        vec![
487            meta("1.0.0", 10, true),
488            meta("1.0.0", 7, true),
489            meta("1.0.0", 3, true),
490        ]
491    }
492
493    #[test]
494    fn latest_confirmed_picks_highest() {
495        let p = RollbackPolicyKind::LatestConfirmed;
496        assert_eq!(p.select_target(Some(15), &confirmed_candidates()), Some(10));
497    }
498
499    #[test]
500    fn latest_confirmed_with_empty_candidates() {
501        let p = RollbackPolicyKind::LatestConfirmed;
502        assert_eq!(p.select_target(Some(15), &[]), None);
503    }
504
505    #[test]
506    fn immediate_previous_picks_just_below_current() {
507        let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
508        assert_eq!(p.select_target(Some(10), &confirmed_candidates()), Some(7));
509    }
510
511    #[test]
512    fn immediate_previous_skips_equal_sequence() {
513        let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
514        // Current is 10, candidates include 10 — should skip to 7
515        assert_eq!(p.select_target(Some(10), &confirmed_candidates()), Some(7));
516    }
517
518    #[test]
519    fn immediate_previous_none_when_no_lower() {
520        let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
521        let candidates = vec![meta("1.0.0", 10, true)];
522        assert_eq!(p.select_target(Some(10), &candidates), None);
523    }
524
525    #[test]
526    fn immediate_previous_none_when_no_current() {
527        let p = RollbackPolicyKind::ImmediatePreviousConfirmed;
528        assert_eq!(p.select_target(None, &confirmed_candidates()), None);
529    }
530
531    #[test]
532    fn embedded_only_always_none() {
533        let p = RollbackPolicyKind::EmbeddedOnly;
534        assert_eq!(p.select_target(Some(10), &confirmed_candidates()), None);
535    }
536
537    #[test]
538    fn rollback_policy_default_is_latest_confirmed() {
539        assert_eq!(
540            RollbackPolicyKind::default(),
541            RollbackPolicyKind::LatestConfirmed
542        );
543    }
544
545    #[test]
546    fn rollback_policy_serde_roundtrip() {
547        for (json, expected) in [
548            ("\"latest_confirmed\"", RollbackPolicyKind::LatestConfirmed),
549            (
550                "\"immediate_previous_confirmed\"",
551                RollbackPolicyKind::ImmediatePreviousConfirmed,
552            ),
553            ("\"embedded_only\"", RollbackPolicyKind::EmbeddedOnly),
554        ] {
555            let parsed: RollbackPolicyKind = serde_json::from_str(json).unwrap();
556            assert_eq!(parsed, expected);
557        }
558    }
559
560    // ── RetentionPolicy ───────────────────────────────────────────
561
562    fn available_versions() -> Vec<HotswapMeta> {
563        vec![
564            meta("1.0.0", 10, true),
565            meta("1.0.0", 7, true),
566            meta("1.0.0", 5, true),
567            meta("1.0.0", 3, true),
568            meta("1.0.0", 1, true),
569        ]
570    }
571
572    #[test]
573    fn retention_default_keeps_two() {
574        let r = RetentionConfig::default();
575        let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
576        assert_eq!(kept.len(), 2);
577        assert!(kept.contains(&10));
578        assert!(kept.contains(&7));
579    }
580
581    #[test]
582    fn retention_three_keeps_three() {
583        let r = RetentionConfig {
584            max_retained_versions: 3,
585        };
586        let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
587        assert_eq!(kept.len(), 3);
588        assert!(kept.contains(&10));
589        assert!(kept.contains(&7));
590        // Third slot goes to next highest available
591        assert!(kept.contains(&5));
592    }
593
594    #[test]
595    fn retention_five_keeps_five() {
596        let r = RetentionConfig {
597            max_retained_versions: 5,
598        };
599        let kept = r.select_kept_sequences(Some(10), Some(7), &available_versions());
600        assert_eq!(kept.len(), 5);
601    }
602
603    #[test]
604    fn retention_preserves_current_and_rollback_even_if_not_in_available() {
605        let r = RetentionConfig::default();
606        // current=10, rollback=7, but available only has [5, 3, 1]
607        let available = vec![
608            meta("1.0.0", 5, true),
609            meta("1.0.0", 3, true),
610            meta("1.0.0", 1, true),
611        ];
612        let kept = r.select_kept_sequences(Some(10), Some(7), &available);
613        assert!(kept.contains(&10));
614        assert!(kept.contains(&7));
615    }
616
617    #[test]
618    fn retention_clamps_below_two() {
619        let r = RetentionConfig {
620            max_retained_versions: 0,
621        };
622        assert_eq!(r.effective_max(), 2);
623
624        let r = RetentionConfig {
625            max_retained_versions: 1,
626        };
627        assert_eq!(r.effective_max(), 2);
628    }
629
630    #[test]
631    fn retention_no_current_no_rollback() {
632        let r = RetentionConfig::default();
633        let kept = r.select_kept_sequences(None, None, &available_versions());
634        // Should still keep up to max from available
635        assert_eq!(kept.len(), 2);
636        assert!(kept.contains(&10));
637        assert!(kept.contains(&7));
638    }
639
640    #[test]
641    fn retention_config_serde_roundtrip() {
642        let json = r#"{"max_retained_versions":4}"#;
643        let parsed: RetentionConfig = serde_json::from_str(json).unwrap();
644        assert_eq!(parsed.max_retained_versions, 4);
645
646        // Default when field omitted
647        let empty: RetentionConfig = serde_json::from_str("{}").unwrap();
648        assert_eq!(empty.max_retained_versions, 2);
649    }
650
651    #[test]
652    fn retention_config_default() {
653        let r = RetentionConfig::default();
654        assert_eq!(r.max_retained_versions, 2);
655    }
656}