Skip to main content

wp_self_update/
lib.rs

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