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;