1mod error;
2mod fetch;
3mod install;
4mod lock;
5mod manifest;
6mod platform;
7mod types;
8mod versioning;
9
10pub use error::{UpdateError, UpdateReason, UpdateResult};
11pub use manifest::updates_manifest_url;
12pub use types::{
13 CheckReport, CheckRequest, GithubReleaseAssetInfo, GithubReleaseInfo, GithubRepo,
14 ResolvedRelease, SourceConfig, SourceKind, UpdateChannel, UpdateProduct, UpdateReport,
15 UpdateRequest, UpdateTarget, VersionRelation,
16};
17pub use versioning::{compare_versions_str, relation_message};
18
19use error::{install_failed, invalid_request, state_conflict};
20use fetch::load_release;
21use install::{
22 confirm_update, create_temp_update_dir, discover_extracted_bins, extract_artifact_archive,
23 fetch_asset_bytes, find_extracted_bins, install_bins, is_gzip_artifact,
24 is_probably_package_managed, resolve_install_dir, rollback_bins, run_health_check,
25 stage_raw_binary, validate_download_url, verify_asset_sha256,
26};
27use lock::UpdateLock;
28use std::path::PathBuf;
29
30pub async fn check(request: CheckRequest) -> UpdateResult<CheckReport> {
31 let channel = request.source.channel;
32 let channel_name = source_channel_name(&request.source).to_string();
33 let manifest_format = source_format_name(&request.source).to_string();
34 let (release, source) = load_release(&request.source, channel).await?;
35 versioning::validate_artifact_version_consistency(&release.version, &release.artifact)?;
36
37 let relation = compare_versions_str(&request.current_version, &release.version)?;
38 Ok(CheckReport {
39 product: request.product,
40 channel: channel_name,
41 branch: request.branch,
42 source,
43 manifest_format,
44 current_version: request.current_version,
45 latest_version: release.version.clone(),
46 update_available: relation == VersionRelation::UpdateAvailable,
47 platform_key: release.target,
48 artifact: release.artifact,
49 sha256: release.sha256,
50 })
51}
52
53pub async fn update(request: UpdateRequest) -> UpdateResult<UpdateReport> {
54 let channel = request.source.channel;
55 let channel_name = source_channel_name(&request.source).to_string();
56 let (release, source) = load_release(&request.source, channel).await?;
57 versioning::validate_artifact_version_consistency(&release.version, &release.artifact)?;
58 validate_download_url(&release.artifact, &request.source)?;
59
60 let relation = compare_versions_str(&request.current_version, &release.version)?;
61 let install_dir = resolve_install_dir(request.install_dir.as_deref())?;
62 let install_dir_display = install_dir.display().to_string();
63
64 if relation != VersionRelation::UpdateAvailable && !request.force {
65 return Ok(UpdateReport {
66 product: request.product.clone(),
67 channel: channel_name.clone(),
68 source,
69 current_version: request.current_version,
70 latest_version: release.version,
71 install_dir: install_dir_display,
72 artifact: release.artifact,
73 dry_run: request.dry_run,
74 updated: false,
75 status: relation_message(relation).to_string(),
76 });
77 }
78
79 if is_probably_package_managed(&install_dir) && !request.force {
80 return Err(state_conflict(format!(
81 "refusing to replace binaries under {}; looks like a package-managed install, rerun with --force if this is intentional",
82 install_dir.display()
83 )));
84 }
85
86 if request.dry_run {
87 return Ok(UpdateReport {
88 product: request.product.clone(),
89 channel: channel_name.clone(),
90 source,
91 current_version: request.current_version,
92 latest_version: release.version,
93 install_dir: install_dir_display,
94 artifact: release.artifact,
95 dry_run: true,
96 updated: false,
97 status: "dry-run".to_string(),
98 });
99 }
100
101 if !request.yes
102 && !confirm_update(
103 &request.current_version,
104 &release.version,
105 &install_dir,
106 &release.artifact,
107 )?
108 {
109 return Ok(UpdateReport {
110 product: request.product.clone(),
111 channel: channel_name.clone(),
112 source,
113 current_version: request.current_version,
114 latest_version: release.version,
115 install_dir: install_dir_display,
116 artifact: release.artifact,
117 dry_run: false,
118 updated: false,
119 status: "aborted".to_string(),
120 });
121 }
122
123 let _lock = UpdateLock::acquire(&install_dir)?;
124 let asset_bytes = fetch_asset_bytes(&release.artifact).await?;
125 verify_asset_sha256(&asset_bytes, &release.sha256)?;
126
127 let extract_root = create_temp_update_dir()?;
128 let install_result = async {
129 let (extracted, selected_bins) =
130 prepare_install_payload(&asset_bytes, &extract_root, &request.target)?;
131 let backup_dir = install_bins(&install_dir, &extracted, &selected_bins)?;
132 if let Err(err) = run_health_check(&install_dir, &release.version, &selected_bins) {
133 rollback_bins(&install_dir, &backup_dir, &selected_bins)?;
134 return Err(err);
135 }
136 Ok::<PathBuf, UpdateError>(backup_dir)
137 }
138 .await;
139
140 let _ = std::fs::remove_dir_all(&extract_root);
141 let backup_dir = install_result?;
142
143 Ok(UpdateReport {
144 product: request.product,
145 channel: channel_name,
146 source,
147 current_version: request.current_version,
148 latest_version: release.version,
149 install_dir: install_dir_display,
150 artifact: release.artifact,
151 dry_run: false,
152 updated: true,
153 status: format!("installed (backup: {})", backup_dir.display()),
154 })
155}
156
157fn source_channel_name(source: &SourceConfig) -> String {
158 match source.kind {
159 SourceKind::Manifest { .. } => source.channel.as_str().to_string(),
160 SourceKind::GithubLatest { .. } => "main".to_string(),
161 SourceKind::GithubTag { ref tag, .. } => tag.clone(),
162 }
163}
164
165fn source_format_name(source: &SourceConfig) -> &'static str {
166 match source.kind {
167 SourceKind::Manifest { .. } => "v2",
168 SourceKind::GithubLatest { .. } | SourceKind::GithubTag { .. } => "github-release",
169 }
170}
171
172fn prepare_install_payload(
173 asset_bytes: &[u8],
174 extract_root: &std::path::Path,
175 target: &UpdateTarget,
176) -> UpdateResult<(std::collections::HashMap<String, PathBuf>, Vec<String>)> {
177 if is_gzip_artifact(asset_bytes) {
178 extract_artifact_archive(asset_bytes, extract_root)?;
179 return resolve_target_bins(extract_root, target);
180 }
181
182 let bins = resolve_raw_binary_bins(target)?;
183 let extracted = stage_raw_binary(asset_bytes, extract_root, &bins[0])?;
184 Ok((extracted, bins))
185}
186
187fn resolve_raw_binary_bins(target: &UpdateTarget) -> UpdateResult<Vec<String>> {
188 match target {
189 UpdateTarget::Product(product) => {
190 let bins = product.owned_bins();
191 if bins.len() == 1 {
192 return Ok(bins);
193 }
194 Err(invalid_request(
195 "raw binary artifacts require exactly one target binary",
196 ))
197 }
198 UpdateTarget::Bins(bins) => {
199 if bins.len() == 1 {
200 return Ok(bins.clone());
201 }
202 Err(invalid_request(
203 "raw binary artifacts require exactly one target binary",
204 ))
205 }
206 UpdateTarget::Auto => {
207 let current_exe = std::env::current_exe().map_err(|e| {
208 install_failed(format!("failed to resolve current executable path: {}", e))
209 })?;
210 let Some(name) = current_exe.file_name().and_then(|value| value.to_str()) else {
211 return Err(install_failed(format!(
212 "failed to resolve executable name from {}",
213 current_exe.display()
214 )));
215 };
216 Ok(vec![name.to_string()])
217 }
218 }
219}
220
221fn resolve_target_bins(
222 extract_root: &std::path::Path,
223 target: &UpdateTarget,
224) -> UpdateResult<(std::collections::HashMap<String, PathBuf>, Vec<String>)> {
225 match target {
226 UpdateTarget::Product(product) => {
227 let bins = product.owned_bins();
228 let extracted = find_extracted_bins(extract_root, &bins)?;
229 Ok((extracted, bins))
230 }
231 UpdateTarget::Bins(bins) => {
232 let extracted = find_extracted_bins(extract_root, bins)?;
233 Ok((extracted, bins.clone()))
234 }
235 UpdateTarget::Auto => {
236 let extracted = discover_extracted_bins(extract_root)?;
237 let mut bins: Vec<String> = extracted.keys().cloned().collect();
238 bins.sort();
239 Ok((extracted, bins))
240 }
241 }
242}
243
244#[doc(hidden)]
245pub use fetch::load_github_release_info;
246#[doc(hidden)]
247pub use install::{
248 extract_artifact_archive as extract_tar_gz_archive, fetch_asset_bytes as download_asset_bytes,
249};
250#[doc(hidden)]
251pub use manifest::{parse_v2_release, updates_manifest_path};
252#[doc(hidden)]
253pub use versioning::validate_artifact_version_consistency;
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::install::{
259 create_temp_update_dir, extract_artifact_archive, find_extracted_bins, install_bins,
260 rollback_bins, run_health_check,
261 };
262 use flate2::write::GzEncoder;
263 use flate2::Compression;
264 use std::fs;
265 #[cfg(unix)]
266 use std::os::unix::fs::PermissionsExt;
267 use std::path::Path;
268 use std::process::Command;
269 use tar::Builder;
270 use tempfile::tempdir;
271
272 fn build_artifact_tar_gz(version: &str, healthy: bool) -> Vec<u8> {
273 let mut out = Vec::new();
274 let encoder = GzEncoder::new(&mut out, Compression::default());
275 let mut builder = Builder::new(encoder);
276 for bin in UpdateProduct::Suite.bins() {
277 let body = if healthy || *bin != "wpadm" {
278 format!("#!/bin/sh\necho \"{} {}\"\n", bin, version)
279 } else {
280 "#!/bin/sh\nexit 1\n".to_string()
281 };
282 let mut header = tar::Header::new_gnu();
283 header.set_size(body.len() as u64);
284 header.set_mode(0o755);
285 header.set_cksum();
286 builder
287 .append_data(&mut header, format!("artifacts/{}", bin), body.as_bytes())
288 .expect("append tar entry");
289 }
290 let encoder = builder.into_inner().expect("finish tar builder");
291 encoder.finish().expect("finish gzip");
292 out
293 }
294
295 fn write_existing_bins(dir: &Path, version: &str) {
296 for bin in UpdateProduct::Suite.bins() {
297 let path = dir.join(bin);
298 fs::write(&path, format!("#!/bin/sh\necho \"{} {}\"\n", bin, version))
299 .expect("write existing bin");
300 #[cfg(unix)]
301 {
302 let mut perms = fs::metadata(&path)
303 .expect("stat existing bin")
304 .permissions();
305 perms.set_mode(0o755);
306 fs::set_permissions(&path, perms).expect("chmod existing bin");
307 }
308 }
309 }
310
311 fn apply_artifact(install_dir: &Path, artifact: &[u8], version: &str) -> UpdateResult<PathBuf> {
312 let extract_root = create_temp_update_dir()?;
313 let install_result = (|| {
314 extract_artifact_archive(artifact, &extract_root)?;
315 let bins = UpdateProduct::Suite.owned_bins();
316 let extracted = find_extracted_bins(&extract_root, &bins)?;
317 let backup_dir = install_bins(install_dir, &extracted, &bins)?;
318 if let Err(err) = run_health_check(install_dir, version, &bins) {
319 rollback_bins(install_dir, &backup_dir, &bins)?;
320 return Err(err);
321 }
322 Ok(backup_dir)
323 })();
324 let _ = std::fs::remove_dir_all(&extract_root);
325 install_result
326 }
327
328 fn build_raw_binary(version: &str, name: &str) -> Vec<u8> {
329 format!("#!/bin/sh\necho \"{} {}\"\n", name, version).into_bytes()
330 }
331
332 fn build_help_only_binary(name: &str) -> Vec<u8> {
333 format!(
334 "#!/bin/sh\nif [ \"$1\" = \"--help\" ] || [ \"$1\" = \"help\" ]; then\n echo \"{} help\"\n exit 0\nfi\nif [ \"$1\" = \"--version\" ] || [ \"$1\" = \"-V\" ] || [ \"$1\" = \"version\" ]; then\n echo \"unknown command: $1\" 1>&2\n exit 1\nfi\necho \"{} help\"\n",
335 name, name
336 )
337 .into_bytes()
338 }
339
340 #[test]
341 fn installs_release_artifact() {
342 let artifact = build_artifact_tar_gz("0.30.0", true);
343 let install_dir = tempdir().expect("install tempdir");
344 write_existing_bins(install_dir.path(), "0.21.0");
345
346 let backup_dir =
347 apply_artifact(install_dir.path(), &artifact, "0.30.0").expect("install artifact");
348 assert!(backup_dir.exists());
349
350 let out = Command::new(install_dir.path().join("wpadm"))
351 .arg("--version")
352 .output()
353 .expect("run installed wpadm");
354 assert!(out.status.success());
355 assert!(String::from_utf8_lossy(&out.stdout).contains("0.30.0"));
356 }
357
358 #[test]
359 fn rolls_back_on_health_check_failure() {
360 let artifact = build_artifact_tar_gz("0.30.0", false);
361 let install_dir = tempdir().expect("install tempdir");
362 write_existing_bins(install_dir.path(), "0.21.0");
363
364 let err =
365 apply_artifact(install_dir.path(), &artifact, "0.30.0").expect_err("expected failure");
366 assert!(format!("{}", err).contains("health check failed"));
367
368 let out = Command::new(install_dir.path().join("wpadm"))
369 .arg("--version")
370 .output()
371 .expect("run rolled back wpadm");
372 assert!(out.status.success());
373 assert!(String::from_utf8_lossy(&out.stdout).contains("0.21.0"));
374 }
375
376 #[test]
377 fn prepares_raw_binary_for_single_bin_targets() {
378 let extract_root = tempdir().expect("extract tempdir");
379 let artifact = build_raw_binary("0.30.0", "wpadm");
380
381 let (extracted, bins) = prepare_install_payload(
382 &artifact,
383 extract_root.path(),
384 &UpdateTarget::Bins(vec!["wpadm".to_string()]),
385 )
386 .expect("prepare raw binary");
387
388 assert_eq!(bins, vec!["wpadm".to_string()]);
389 assert!(extracted.contains_key("wpadm"));
390 }
391
392 #[test]
393 fn rejects_raw_binary_for_multi_bin_targets() {
394 let extract_root = tempdir().expect("extract tempdir");
395 let artifact = build_raw_binary("0.30.0", "suite");
396
397 let err = prepare_install_payload(
398 &artifact,
399 extract_root.path(),
400 &UpdateTarget::Product(UpdateProduct::Suite),
401 )
402 .expect_err("expected rejection");
403
404 assert!(format!("{}", err).contains("exactly one target binary"));
405 }
406
407 #[test]
408 fn installs_raw_binary_that_only_supports_help_probe() {
409 let artifact = build_help_only_binary("wpl-check");
410 let install_dir = tempdir().expect("install tempdir");
411
412 let extract_root = create_temp_update_dir().expect("extract root");
413 let install_result = (|| {
414 let (extracted, bins) = prepare_install_payload(
415 &artifact,
416 &extract_root,
417 &UpdateTarget::Bins(vec!["wpl-check".to_string()]),
418 )?;
419 let backup_dir = install_bins(install_dir.path(), &extracted, &bins)?;
420 if let Err(err) = run_health_check(install_dir.path(), "0.30.0", &bins) {
421 rollback_bins(install_dir.path(), &backup_dir, &bins)?;
422 return Err(err);
423 }
424 Ok::<PathBuf, UpdateError>(backup_dir)
425 })();
426 let _ = std::fs::remove_dir_all(&extract_root);
427
428 assert!(install_result.is_ok());
429 assert!(install_dir.path().join("wpl-check").exists());
430 }
431}