Skip to main content

tauri_plugin_hot_update/
update.rs

1//! The update-acquisition pipeline: manifest fetch → signature verification
2//! → gates → archive download (sha256 streamed) → hardened extraction →
3//! atomic staging. This is what WP4's `check`/`download` commands call.
4//!
5//! Pipeline order is the verification chain from the design doc — each step
6//! consumes only the previous step's *verified* output, and every gate is a
7//! hard stop (typed refusal or error), never a warn-and-continue:
8//!
9//! 1. fetch manifest bytes + detached `.minisig` (capped bodies)
10//! 2. minisign verify against the embedded trusted-key list (rotation-ready)
11//! 3. parse + validate JSON
12//! 4. gates against persisted state: `version > maxVersionSeen` (downgrade
13//!    replay), archive sha256 not blacklisted, `minShellVersion <= shell`
14//! 5. stream archive to a temp file under `bundles/`, hashing en route;
15//!    exact size + sha256 enforced before anything touches the bytes
16//! 6. extract (hardened) into a temp dir under `bundles/`
17//! 7. `allocate_seq` → atomic rename to `bundles/seq-N/` → `machine::stage`
18//!    via `Store::update` (which re-checks the gates — belt and braces)
19//!
20//! A crash anywhere leaves at worst a temp file/dir under `bundles/`, which
21//! the next boot's GC sweeps. Idempotent: an offered version that is already
22//! staged or committed is reported as such, not re-downloaded.
23
24use std::fs;
25
26use semver::Version;
27use serde::Serialize;
28
29use crate::download::{
30    download_archive, fetch_capped, http_client, MANIFEST_MAX_BYTES, SIGNATURE_MAX_BYTES,
31};
32use crate::machine::{self, BundleMeta, State};
33use crate::manifest::{self, Manifest};
34use crate::runtime::HotUpdate;
35use crate::{Error, Result};
36
37/// Where updates come from and who is trusted to sign them. The plugin
38/// setup hook builds this from the validated [`crate::Config`]; Rust-side
39/// callers may construct it directly.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct UpdateConfig {
42    /// URL of `manifest.json`. The detached signature is fetched from
43    /// `{manifest_url}.minisig`, so the URL must be a plain file URL
44    /// (no query strings).
45    pub manifest_url: String,
46    /// Trusted minisign public keys — raw base64 or full `minisign.pub`
47    /// contents. A manifest verifying under ANY key is trusted (rotation:
48    /// ship old + new during a transition).
49    pub pubkeys: Vec<String>,
50}
51
52/// Result of a check or download pass. Refusals are first-class outcomes,
53/// not errors: they are the pipeline saying "this manifest is not for us",
54/// with the reason preserved for the app/UI.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(
57    tag = "status",
58    rename_all = "camelCase",
59    rename_all_fields = "camelCase"
60)]
61pub enum UpdateOutcome {
62    /// `check()` only: a verified, applicable update is offered.
63    Available { manifest: Manifest },
64    /// `check_and_download()` only: downloaded, verified, extracted, staged
65    /// for the next cold launch.
66    Staged { seq: u64, version: Version },
67    /// The offered version is not strictly newer than the watermark. This is
68    /// both the everyday "no update" answer and the downgrade-replay
69    /// rejection — an old validly-signed manifest lands here.
70    UpToDate {
71        offered: Version,
72        watermark: Version,
73    },
74    /// The offered archive hash previously failed a trial boot and is
75    /// permanently blacklisted; a fixed release ships under a new hash.
76    Blacklisted { version: Version },
77    /// The bundle requires a newer shell; this install stays put until a
78    /// store update arrives.
79    ShellTooOld { required: Version, shell: Version },
80    /// Exactly this archive is already staged and waiting for its trial
81    /// boot; nothing to do.
82    AlreadyStaged { seq: u64, version: Version },
83}
84
85impl HotUpdate {
86    /// Fetch and verify the manifest, then report whether it is applicable.
87    /// Never downloads the archive. Errors are verification/transport
88    /// failures; gate refusals are `Ok` outcomes.
89    pub async fn check(&self, config: &UpdateConfig) -> Result<UpdateOutcome> {
90        let activation = self.shared.activation()?;
91        let _serialized = activation.update_lock().lock().await;
92        let client = http_client()?;
93        let manifest = fetch_verified_manifest(&client, config).await?;
94        let state = activation.store().load_state();
95        Ok(
96            match gate(&manifest, &state, activation.embedded_version()) {
97                Some(refusal) => refusal,
98                None => UpdateOutcome::Available { manifest },
99            },
100        )
101    }
102
103    /// The full pipeline: check, and if an update is applicable, download,
104    /// verify, extract, and stage it for the next cold launch.
105    ///
106    /// `on_progress(downloaded, total)` fires per received archive chunk.
107    /// Concurrent calls are serialized; the loser of the race then reports
108    /// `AlreadyStaged`/`UpToDate` instead of downloading twice.
109    pub async fn check_and_download(
110        &self,
111        config: &UpdateConfig,
112        mut on_progress: impl FnMut(u64, u64) + Send,
113    ) -> Result<UpdateOutcome> {
114        let activation = self.shared.activation()?;
115        let _serialized = activation.update_lock().lock().await;
116        let client = http_client()?;
117        let manifest = fetch_verified_manifest(&client, config).await?;
118        let store = activation.store();
119        if let Some(refusal) = gate(
120            &manifest,
121            &store.load_state(),
122            activation.embedded_version(),
123        ) {
124            return Ok(refusal);
125        }
126
127        // Download to a temp file under bundles/: deleted on drop on any
128        // failure, and swept by boot GC even after a hard kill.
129        let bundles_dir = store.bundles_dir();
130        fs::create_dir_all(&bundles_dir)?;
131        let mut archive_file = tempfile::Builder::new()
132            .prefix("dl-")
133            .suffix(".part")
134            .tempfile_in(&bundles_dir)?;
135        download_archive(
136            &client,
137            &manifest.archive,
138            archive_file.as_file_mut(),
139            &mut on_progress,
140        )
141        .await?;
142
143        // Extract into a temp dir next to the final location (same
144        // filesystem, so the promotion below is a true atomic rename).
145        let extract_dir = tempfile::Builder::new()
146            .prefix("extract-")
147            .tempdir_in(&bundles_dir)?;
148        let archive_path = archive_file.path().to_path_buf();
149        let target = extract_dir.path().to_path_buf();
150        tokio::task::spawn_blocking(move || crate::extract::extract_tar_gz(&archive_path, &target))
151            .await
152            .map_err(|e| Error::Io(std::io::Error::other(e)))??;
153        drop(archive_file);
154
155        // Promote: allocate a never-used seq, atomically rename the fully
156        // extracted dir into place, then flip the staged pointer. A crash
157        // between rename and stage leaves an orphan dir for boot GC.
158        let seq = store.allocate_seq(&store.load_state());
159        let final_dir = store.bundle_dir(seq);
160        fs::rename(extract_dir.keep(), &final_dir)?;
161        let meta = BundleMeta {
162            version: manifest.version.clone(),
163            archive_sha256: manifest.archive.sha256.clone(),
164        };
165        match store.update(|state| machine::stage(state, seq, meta))? {
166            Ok(effects) => {
167                store.apply_effects(&effects);
168                log::info!(
169                    "hot-update: bundle v{} staged as seq {seq} for the next launch",
170                    manifest.version
171                );
172                Ok(UpdateOutcome::Staged {
173                    seq,
174                    version: manifest.version,
175                })
176            }
177            Err(refused) => {
178                // State changed between the gate check and staging (e.g. a
179                // reset). The machine's refusal stands; discard the dir.
180                let _ = fs::remove_dir_all(&final_dir);
181                Err(Error::StageRefused(refused))
182            }
183        }
184    }
185}
186
187/// Fetch manifest + detached signature, verify against the trusted keys,
188/// parse, validate. Nothing downstream sees unverified bytes.
189async fn fetch_verified_manifest(
190    client: &reqwest::Client,
191    config: &UpdateConfig,
192) -> Result<Manifest> {
193    let manifest_bytes = fetch_capped(client, &config.manifest_url, MANIFEST_MAX_BYTES).await?;
194    let signature_url = format!("{}.minisig", config.manifest_url);
195    let signature_bytes = fetch_capped(client, &signature_url, SIGNATURE_MAX_BYTES).await?;
196    let signature = String::from_utf8(signature_bytes)
197        .map_err(|_| Error::ManifestSignature("signature file is not UTF-8".into()))?;
198    manifest::verify_and_parse(&manifest_bytes, &signature, &config.pubkeys)
199}
200
201/// Manifest-accept gates, in refusal-precedence order. `None` means the
202/// manifest is applicable. `machine::stage` re-checks the watermark and
203/// blacklist at staging time — these gates simply refuse earlier, before
204/// any bytes are downloaded.
205fn gate(manifest: &Manifest, state: &State, shell: &Version) -> Option<UpdateOutcome> {
206    if let Some(watermark) = &state.max_version_seen {
207        if manifest.version <= *watermark {
208            return Some(UpdateOutcome::UpToDate {
209                offered: manifest.version.clone(),
210                watermark: watermark.clone(),
211            });
212        }
213    }
214    if state.failed.contains(&manifest.archive.sha256) {
215        return Some(UpdateOutcome::Blacklisted {
216            version: manifest.version.clone(),
217        });
218    }
219    if manifest.min_shell_version > *shell {
220        return Some(UpdateOutcome::ShellTooOld {
221            required: manifest.min_shell_version.clone(),
222            shell: shell.clone(),
223        });
224    }
225    if let Some(seq) = state.staged {
226        if state
227            .versions
228            .get(&seq)
229            .is_some_and(|meta| meta.archive_sha256 == manifest.archive.sha256)
230        {
231            return Some(UpdateOutcome::AlreadyStaged {
232                seq,
233                version: manifest.version.clone(),
234            });
235        }
236    }
237    None
238}
239
240#[cfg(test)]
241mod tests;