Skip to main content

wp_self_update/
lib.rs

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}