Skip to main content

winx_code_agent/
update.rs

1//! Self-update from GitHub Releases.
2//!
3//! `winx-code-agent update` fetches the release metadata, downloads the bundle
4//! for this platform together with the published `SHA256SUMS`, verifies the
5//! digest, extracts the three sibling binaries, and swaps them in next to the
6//! running executable. Downloads go through the system `curl` and extraction
7//! through the system `tar` (bsdtar on Windows also reads zip archives), so the
8//! server binary carries no TLS stack of its own and inherits the user's proxy
9//! configuration. Running daemons keep the old executables alive until they
10//! exit; `restart-daemon` picks up the new `winxd`, and guardians are replaced
11//! as their sessions end.
12
13use std::collections::HashMap;
14use std::ffi::OsStr;
15use std::fmt::Write as _;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde::{Deserialize, Serialize};
20use sha2::{Digest as _, Sha256};
21
22use crate::errors::{Result, WinxError};
23
24/// GitHub repository that publishes the release bundles.
25pub const REPOSITORY: &str = "gabrielmaialva33/winx-code-agent";
26/// Checksum manifest published with every release.
27pub const CHECKSUM_ASSET: &str = "SHA256SUMS";
28/// Sibling binaries every bundle must contain.
29pub const BINARIES: [&str; 3] = ["winx-code-agent", "winxd", "winx-guardian"];
30
31/// A published release and its downloadable assets.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Release {
34    pub tag: String,
35    pub version: String,
36    pub assets: Vec<ReleaseAsset>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct ReleaseAsset {
41    pub name: String,
42    pub url: String,
43}
44
45/// What `update` decided and did.
46#[derive(Debug, Clone, Serialize)]
47pub struct UpdateReport {
48    pub current_version: String,
49    pub latest_version: String,
50    pub latest_tag: String,
51    pub asset: String,
52    pub install_dir: PathBuf,
53    pub action: UpdateAction,
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub installed: Vec<PathBuf>,
56    pub next_step: &'static str,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "snake_case")]
61pub enum UpdateAction {
62    /// Nothing downloaded: `--check`, or already on the requested version.
63    UpToDate,
64    /// `--check` found a newer version and stopped.
65    UpdateAvailable,
66    /// The bundle was verified and installed.
67    Installed,
68}
69
70/// Options for [`run`].
71#[derive(Debug, Clone, Default)]
72pub struct UpdateOptions {
73    /// Report only; never download.
74    pub check_only: bool,
75    /// Install this tag (`v0.2.351`) instead of the latest release.
76    pub tag: Option<String>,
77    /// Reinstall even when the version already matches.
78    pub force: bool,
79    /// Directory to install into; defaults to the running executable's directory.
80    pub install_dir: Option<PathBuf>,
81}
82
83/// Resolve, verify, and install a release according to `options`.
84pub fn run(options: &UpdateOptions) -> Result<UpdateReport> {
85    let current_version = crate::build_info::package_version().to_string();
86    let asset_name = platform_asset_name()?;
87    let install_dir = match &options.install_dir {
88        Some(dir) => dir.clone(),
89        None => default_install_dir()?,
90    };
91    let release = fetch_release(options.tag.as_deref())?;
92    let newer = version_is_newer(&release.version, &current_version);
93    let same = release.version == current_version;
94    let mut report = UpdateReport {
95        current_version,
96        latest_version: release.version.clone(),
97        latest_tag: release.tag.clone(),
98        asset: asset_name.to_string(),
99        install_dir: install_dir.clone(),
100        action: UpdateAction::UpToDate,
101        installed: Vec::new(),
102        next_step: "nothing to do",
103    };
104
105    // One decision for `--check` and for the install: an implicit "latest"
106    // only moves forward, an explicit `--tag` may go anywhere, and `--force`
107    // reinstalls even the same version.
108    let wanted = should_install(options.tag.is_some(), newer, same);
109    if options.check_only {
110        if wanted {
111            report.action = UpdateAction::UpdateAvailable;
112            report.next_step = "run `winx-code-agent update` to install it";
113        }
114        return Ok(report);
115    }
116    if !wanted && !options.force {
117        report.next_step = if same {
118            "already on this version; pass --force to reinstall"
119        } else {
120            "installed build is newer than the latest release; pass --tag to pin a release or \
121             --force to reinstall"
122        };
123        return Ok(report);
124    }
125
126    let asset = release.assets.iter().find(|asset| asset.name == asset_name).ok_or_else(|| {
127        WinxError::ConfigurationError(format!(
128            "release {} publishes no {asset_name} asset",
129            release.tag
130        ))
131    })?;
132    let checksums =
133        release.assets.iter().find(|asset| asset.name == CHECKSUM_ASSET).ok_or_else(|| {
134            WinxError::ConfigurationError(format!(
135                "release {} publishes no {CHECKSUM_ASSET} asset",
136                release.tag
137            ))
138        })?;
139
140    let workdir = tempfile::Builder::new().prefix("winx-update-").tempdir().map_err(|error| {
141        WinxError::CommandExecutionError(format!("cannot create a download directory: {error}"))
142    })?;
143    let bundle = workdir.path().join(asset_name);
144    download(&asset.url, &bundle)?;
145    let manifest = String::from_utf8_lossy(&fetch_bytes(&checksums.url)?).into_owned();
146    verify_checksum(&bundle, asset_name, &manifest)?;
147
148    let extracted = workdir.path().join("extracted");
149    std::fs::create_dir_all(&extracted).map_err(|error| {
150        WinxError::CommandExecutionError(format!("cannot create {}: {error}", extracted.display()))
151    })?;
152    extract(&bundle, &extracted)?;
153    let binaries = locate_binaries(&extracted)?;
154
155    std::fs::create_dir_all(&install_dir).map_err(|error| {
156        WinxError::CommandExecutionError(format!(
157            "cannot create {}: {error}",
158            install_dir.display()
159        ))
160    })?;
161    // One update per install directory at a time: two concurrent runs would
162    // otherwise commit each other's staged files and leave a mixed bundle.
163    let _install_lock = InstallLock::acquire(&install_dir)?;
164    // Stage all three beside their targets first, so a full disk, a name
165    // collision, or a permission problem surfaces before any executable is
166    // touched; then commit the daemons and the adapter last, since the
167    // adapter's version is what a later `update` compares against.
168    let mut staged = Vec::with_capacity(binaries.len());
169    for source in binaries.iter().rev() {
170        let file_name = source.file_name().map(OsStr::to_os_string).unwrap_or_default();
171        let destination = install_dir.join(file_name);
172        let staging =
173            stage_binary(source, &destination).inspect_err(|_| discard_staged(&staged))?;
174        staged.push((staging, destination));
175    }
176    for (staging, destination) in staged {
177        commit_binary(&staging, &destination)?;
178        report.installed.push(destination);
179    }
180    report.action = UpdateAction::Installed;
181    report.next_step = "run `winx-code-agent restart-daemon` so winxd uses the new build; \
182                        guardians switch as their sessions end";
183    Ok(report)
184}
185
186/// Whether a release is worth installing without `--force`: an implicit
187/// "latest" never downgrades, an explicit tag installs anything but the
188/// current version.
189fn should_install(explicit_tag: bool, newer: bool, same: bool) -> bool {
190    if explicit_tag {
191        !same
192    } else {
193        newer
194    }
195}
196
197/// Release asset built for this operating system and architecture.
198pub fn platform_asset_name() -> Result<&'static str> {
199    match (std::env::consts::OS, std::env::consts::ARCH) {
200        ("linux", "x86_64") => Ok("winx-linux-amd64.tar.gz"),
201        ("macos", "aarch64") => Ok("winx-macos-arm64.tar.gz"),
202        ("windows", "x86_64") => Ok("winx-windows-amd64.zip"),
203        (os, arch) => Err(WinxError::ConfigurationError(format!(
204            "no prebuilt release bundle for {os}/{arch}; build from source with `cargo install \
205             winx-code-agent`"
206        ))),
207    }
208}
209
210/// Directory that holds the running executable, with symlinks resolved so the
211/// swap happens where the files really live.
212pub fn default_install_dir() -> Result<PathBuf> {
213    let executable = std::env::current_exe().map_err(|error| {
214        WinxError::ConfigurationError(format!("cannot locate the current executable: {error}"))
215    })?;
216    let executable =
217        executable.canonicalize().map(crate::utils::path::simplified).map_err(|error| {
218            WinxError::ConfigurationError(format!(
219                "cannot resolve {}: {error}",
220                executable.display()
221            ))
222        })?;
223    executable.parent().map(Path::to_path_buf).ok_or_else(|| {
224        WinxError::ConfigurationError(format!("{} has no parent directory", executable.display()))
225    })
226}
227
228/// Fetch release metadata for `tag`, or the latest release when `None`.
229pub fn fetch_release(tag: Option<&str>) -> Result<Release> {
230    let url = match tag {
231        Some(tag) => format!("https://api.github.com/repos/{REPOSITORY}/releases/tags/{tag}"),
232        None => format!("https://api.github.com/repos/{REPOSITORY}/releases/latest"),
233    };
234    let body = run_curl(&["-H", "Accept: application/vnd.github+json", &url], None)?;
235    parse_release(&String::from_utf8_lossy(&body))
236}
237
238/// Parse the GitHub release API response.
239pub fn parse_release(json: &str) -> Result<Release> {
240    #[derive(Deserialize)]
241    struct ApiAsset {
242        name: String,
243        browser_download_url: String,
244    }
245    #[derive(Deserialize)]
246    struct ApiRelease {
247        tag_name: String,
248        #[serde(default)]
249        assets: Vec<ApiAsset>,
250    }
251    let release: ApiRelease = serde_json::from_str(json).map_err(|error| {
252        WinxError::SerializationError(format!("unexpected release metadata: {error}"))
253    })?;
254    let version = release.tag_name.trim_start_matches('v').to_string();
255    Ok(Release {
256        tag: release.tag_name,
257        version,
258        assets: release
259            .assets
260            .into_iter()
261            .map(|asset| ReleaseAsset { name: asset.name, url: asset.browser_download_url })
262            .collect(),
263    })
264}
265
266/// `true` when `candidate` is a strictly newer `MAJOR.MINOR.PATCH` than `current`.
267/// Unparseable versions never count as newer.
268pub fn version_is_newer(candidate: &str, current: &str) -> bool {
269    match (parse_version(candidate), parse_version(current)) {
270        (Some(candidate), Some(current)) => candidate > current,
271        _ => false,
272    }
273}
274
275fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
276    let core = text.trim().trim_start_matches('v');
277    let core = core.split(['+', '-']).next()?;
278    let mut parts = core.split('.').map(str::parse::<u64>);
279    let major = parts.next()?.ok()?;
280    let minor = parts.next()?.ok()?;
281    let patch = parts.next()?.ok()?;
282    if parts.next().is_some() {
283        return None;
284    }
285    Some((major, minor, patch))
286}
287
288/// Parse a `sha256sum`-style manifest into `file name -> lowercase hex digest`.
289pub fn parse_checksums(manifest: &str) -> HashMap<String, String> {
290    manifest
291        .lines()
292        .filter_map(|line| {
293            let mut parts = line.split_whitespace();
294            let digest = parts.next()?;
295            let name = parts.next()?.trim_start_matches('*');
296            (digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()))
297                .then(|| (name.to_string(), digest.to_ascii_lowercase()))
298        })
299        .collect()
300}
301
302/// Compare the digest of `path` against the manifest entry for `asset_name`.
303pub fn verify_checksum(path: &Path, asset_name: &str, manifest: &str) -> Result<()> {
304    let expected = parse_checksums(manifest).remove(asset_name).ok_or_else(|| {
305        WinxError::ConfigurationError(format!("{CHECKSUM_ASSET} has no entry for {asset_name}"))
306    })?;
307    let data = std::fs::read(path).map_err(|error| {
308        WinxError::CommandExecutionError(format!("cannot read {}: {error}", path.display()))
309    })?;
310    let actual = Sha256::digest(&data).iter().fold(String::new(), |mut hex, byte| {
311        let _ = write!(hex, "{byte:02x}");
312        hex
313    });
314    if actual != expected {
315        return Err(WinxError::ConfigurationError(format!(
316            "checksum mismatch for {asset_name}: expected {expected}, downloaded {actual}; \
317             the download was discarded"
318        )));
319    }
320    Ok(())
321}
322
323/// Find the three sibling binaries under an extracted bundle.
324pub fn locate_binaries(extracted: &Path) -> Result<Vec<PathBuf>> {
325    let suffix = std::env::consts::EXE_SUFFIX;
326    let mut found = Vec::new();
327    for name in BINARIES {
328        let file_name = format!("{name}{suffix}");
329        let direct = extracted.join(&file_name);
330        let path =
331            if direct.is_file() { Some(direct) } else { find_file(extracted, &file_name, 3) };
332        match path {
333            Some(path) => found.push(path),
334            None => {
335                return Err(WinxError::ConfigurationError(format!(
336                    "the bundle does not contain {file_name}"
337                )))
338            }
339        }
340    }
341    Ok(found)
342}
343
344fn find_file(root: &Path, file_name: &str, depth: usize) -> Option<PathBuf> {
345    if depth == 0 {
346        return None;
347    }
348    let entries = std::fs::read_dir(root).ok()?;
349    let mut directories = Vec::new();
350    for entry in entries.flatten() {
351        let path = entry.path();
352        if path.is_file() && path.file_name().is_some_and(|name| name == file_name) {
353            return Some(path);
354        }
355        if path.is_dir() {
356            directories.push(path);
357        }
358    }
359    directories.into_iter().find_map(|dir| find_file(&dir, file_name, depth - 1))
360}
361
362/// Swap `source` into `destination` without ever leaving a half-written
363/// executable in place: [`stage_binary`] then [`commit_binary`].
364pub fn replace_binary(source: &Path, destination: &Path) -> Result<()> {
365    let staged = stage_binary(source, destination)?;
366    commit_binary(&staged, destination)
367}
368
369/// Exclusive advisory lock on `<install_dir>/.winx-update.lock`, held for the
370/// duration of one update.
371#[derive(Debug)]
372struct InstallLock {
373    _file: std::fs::File,
374}
375
376impl InstallLock {
377    fn acquire(install_dir: &Path) -> Result<Self> {
378        let path = install_dir.join(".winx-update.lock");
379        let file = std::fs::OpenOptions::new()
380            .read(true)
381            .write(true)
382            .create(true)
383            .truncate(false)
384            .open(&path)
385            .map_err(|error| {
386                WinxError::CommandExecutionError(format!("cannot open {}: {error}", path.display()))
387            })?;
388        match file.try_lock() {
389            Ok(()) => Ok(Self { _file: file }),
390            Err(std::fs::TryLockError::WouldBlock) => Err(WinxError::CommandExecutionError(
391                format!("another update is running in {}", install_dir.display()),
392            )),
393            Err(std::fs::TryLockError::Error(error)) => Err(WinxError::CommandExecutionError(
394                format!("cannot lock {}: {error}", path.display()),
395            )),
396        }
397    }
398}
399
400/// Copy `source` to a private `<destination>.new-<pid>-<random>` (same
401/// directory, so the later rename is atomic) and mark it executable. Nothing
402/// in place changes yet, and no other process can hand us its file.
403pub fn stage_binary(source: &Path, destination: &Path) -> Result<PathBuf> {
404    let staged = sibling_with_suffix(
405        destination,
406        &format!(".new-{}-{:08x}", std::process::id(), rand::random::<u32>()),
407    );
408    if std::fs::symlink_metadata(&staged).is_ok() {
409        return Err(WinxError::CommandExecutionError(format!(
410            "cannot stage {}: something is already in the way",
411            staged.display()
412        )));
413    }
414    let mut reader = std::fs::File::open(source).map_err(|error| {
415        WinxError::CommandExecutionError(format!("cannot read {}: {error}", source.display()))
416    })?;
417    let mut writer = std::fs::OpenOptions::new()
418        .write(true)
419        .create_new(true)
420        .open(&staged)
421        .map_err(|error| {
422            WinxError::CommandExecutionError(format!("cannot stage {}: {error}", staged.display()))
423        })?;
424    std::io::copy(&mut reader, &mut writer).map_err(|error| {
425        let _ = std::fs::remove_file(&staged);
426        WinxError::CommandExecutionError(format!("cannot stage {}: {error}", staged.display()))
427    })?;
428    drop(writer);
429    #[cfg(unix)]
430    {
431        use std::os::unix::fs::PermissionsExt as _;
432        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).map_err(
433            |error| {
434                let _ = std::fs::remove_file(&staged);
435                WinxError::CommandExecutionError(format!(
436                    "cannot mark {} executable: {error}",
437                    staged.display()
438                ))
439            },
440        )?;
441    }
442    Ok(staged)
443}
444
445fn discard_staged(staged: &[(PathBuf, PathBuf)]) {
446    for (staging, _) in staged {
447        let _ = std::fs::remove_file(staging);
448    }
449}
450
451/// Move a staged file over `destination`. On Windows a running executable
452/// cannot be overwritten but can be renamed, so the old one moves aside first.
453pub fn commit_binary(staged: &Path, destination: &Path) -> Result<()> {
454    if cfg!(windows) && destination.exists() {
455        let retired = sibling_with_suffix(destination, ".old");
456        let _ = std::fs::remove_file(&retired);
457        std::fs::rename(destination, &retired).map_err(|error| {
458            WinxError::CommandExecutionError(format!(
459                "cannot move the running {} aside: {error}",
460                destination.display()
461            ))
462        })?;
463    }
464    std::fs::rename(staged, destination).map_err(|error| {
465        let _ = std::fs::remove_file(staged);
466        WinxError::CommandExecutionError(format!(
467            "cannot install {}: {error}",
468            destination.display()
469        ))
470    })?;
471    if cfg!(windows) {
472        // Fails while the old executable is still running; the next update
473        // removes it.
474        let _ = std::fs::remove_file(sibling_with_suffix(destination, ".old"));
475    }
476    Ok(())
477}
478
479fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
480    let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
481    name.push(suffix);
482    path.with_file_name(name)
483}
484
485fn download(url: &str, destination: &Path) -> Result<()> {
486    let destination_text = destination.to_string_lossy().into_owned();
487    run_curl(&["-o", &destination_text, url], None)?;
488    Ok(())
489}
490
491fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
492    run_curl(&[url], None)
493}
494
495/// Run the system `curl` with fail-closed defaults and return its stdout.
496fn run_curl(args: &[&str], cwd: Option<&Path>) -> Result<Vec<u8>> {
497    let user_agent = format!("winx-code-agent/{}", crate::build_info::package_version());
498    let mut command = Command::new("curl");
499    command
500        .args(["--fail", "--silent", "--show-error", "--location", "--retry", "3"])
501        .args(["--user-agent", &user_agent])
502        .args(args);
503    if let Some(cwd) = cwd {
504        command.current_dir(cwd);
505    }
506    let output = command.output().map_err(|error| {
507        if error.kind() == std::io::ErrorKind::NotFound {
508            WinxError::ConfigurationError(
509                "`curl` is required for `update`; install it or download the release bundle \
510                 manually"
511                    .to_string(),
512            )
513        } else {
514            WinxError::CommandExecutionError(format!("cannot run curl: {error}"))
515        }
516    })?;
517    if !output.status.success() {
518        return Err(WinxError::CommandExecutionError(format!(
519            "curl failed ({}): {}",
520            output.status,
521            String::from_utf8_lossy(&output.stderr).trim()
522        )));
523    }
524    Ok(output.stdout)
525}
526
527/// Extract a `.tar.gz` or `.zip` bundle with the system `tar`.
528fn extract(bundle: &Path, into: &Path) -> Result<()> {
529    let output = Command::new("tar").arg("-xf").arg(bundle).arg("-C").arg(into).output().map_err(
530        |error| {
531            if error.kind() == std::io::ErrorKind::NotFound {
532                WinxError::ConfigurationError(
533                    "`tar` is required for `update`; install it or extract the bundle manually"
534                        .to_string(),
535                )
536            } else {
537                WinxError::CommandExecutionError(format!("cannot run tar: {error}"))
538            }
539        },
540    )?;
541    if !output.status.success() {
542        return Err(WinxError::CommandExecutionError(format!(
543            "tar failed ({}): {}",
544            output.status,
545            String::from_utf8_lossy(&output.stderr).trim()
546        )));
547    }
548    Ok(())
549}
550
551#[cfg(test)]
552mod tests {
553    #![allow(clippy::expect_used)]
554    use super::*;
555
556    const RELEASE_JSON: &str = r#"{
557        "tag_name": "v0.2.351",
558        "name": "v0.2.351",
559        "assets": [
560            {"name": "SHA256SUMS", "browser_download_url": "https://example.invalid/SHA256SUMS"},
561            {"name": "winx-linux-amd64.tar.gz", "browser_download_url": "https://example.invalid/linux"},
562            {"name": "winx-windows-amd64.zip", "browser_download_url": "https://example.invalid/win"}
563        ]
564    }"#;
565
566    #[test]
567    fn release_metadata_parses_tag_version_and_assets() {
568        let release = parse_release(RELEASE_JSON).expect("release");
569        assert_eq!(release.tag, "v0.2.351");
570        assert_eq!(release.version, "0.2.351");
571        assert_eq!(release.assets.len(), 3);
572        assert_eq!(release.assets[1].name, "winx-linux-amd64.tar.gz");
573        assert_eq!(release.assets[1].url, "https://example.invalid/linux");
574    }
575
576    #[test]
577    fn release_metadata_without_assets_is_still_a_release() {
578        let release = parse_release(r#"{"tag_name":"v9.9.9"}"#).expect("release");
579        assert!(release.assets.is_empty(), "no assets expected");
580        assert!(parse_release("not json").is_err());
581    }
582
583    #[test]
584    fn version_comparison_is_numeric_and_ignores_build_metadata() {
585        assert!(version_is_newer("0.2.352", "0.2.351"));
586        assert!(version_is_newer("v0.3.0", "0.2.999"));
587        assert!(version_is_newer("1.0.0+gabc", "0.9.9"));
588        assert!(!version_is_newer("0.2.351", "0.2.351"));
589        assert!(version_is_newer("0.2.10", "0.2.9+dirty"));
590        assert!(version_is_newer("0.2.10", "0.2.9"));
591        assert!(!version_is_newer("0.2.9", "0.2.10"));
592        assert!(!version_is_newer("garbage", "0.2.351"));
593        assert!(!version_is_newer("0.2", "0.1.0"));
594    }
595
596    #[test]
597    fn implicit_latest_never_downgrades_but_an_explicit_tag_may() {
598        // (explicit_tag, newer, same)
599        assert!(should_install(false, true, false), "latest is newer");
600        assert!(!should_install(false, false, true), "already current");
601        assert!(!should_install(false, false, false), "installed build is newer than latest");
602        assert!(should_install(true, false, false), "pinned older tag");
603        assert!(should_install(true, true, false), "pinned newer tag");
604        assert!(!should_install(true, false, true), "pinned current tag");
605    }
606
607    #[test]
608    fn checksum_manifest_parses_sha256sum_output() {
609        let manifest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef  winx-linux-amd64.tar.gz\n\
610                        ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 *winx-windows-amd64.zip\n\
611                        not-a-digest  junk\n";
612        let sums = parse_checksums(manifest);
613        assert_eq!(sums.len(), 2);
614        assert_eq!(
615            sums["winx-linux-amd64.tar.gz"],
616            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
617        );
618        assert_eq!(
619            sums["winx-windows-amd64.zip"],
620            "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
621        );
622    }
623
624    #[test]
625    fn checksum_verification_accepts_matching_and_rejects_tampered_bundles() {
626        let dir = tempfile::tempdir().expect("tempdir");
627        let bundle = dir.path().join("bundle.tar.gz");
628        std::fs::write(&bundle, b"hello").expect("write");
629        let digest = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
630        let manifest = format!("{digest}  bundle.tar.gz\n");
631        verify_checksum(&bundle, "bundle.tar.gz", &manifest).expect("digest matches");
632        std::fs::write(&bundle, b"hello!").expect("write");
633        let error = verify_checksum(&bundle, "bundle.tar.gz", &manifest).expect_err("mismatch");
634        assert!(error.to_string().contains("checksum mismatch"), "{error}");
635        let error = verify_checksum(&bundle, "other", &manifest).expect_err("missing entry");
636        assert!(error.to_string().contains("no entry"), "{error}");
637    }
638
639    #[test]
640    fn binaries_are_located_at_the_root_or_one_level_down() {
641        let dir = tempfile::tempdir().expect("tempdir");
642        let nested = dir.path().join("stage");
643        std::fs::create_dir_all(&nested).expect("mkdir");
644        let suffix = std::env::consts::EXE_SUFFIX;
645        for name in BINARIES {
646            std::fs::write(nested.join(format!("{name}{suffix}")), b"x").expect("write");
647        }
648        let found = locate_binaries(dir.path()).expect("binaries");
649        assert_eq!(found.len(), 3);
650        assert!(found.iter().all(|path| path.starts_with(&nested)));
651        std::fs::remove_file(nested.join(format!("winxd{suffix}"))).expect("remove");
652        let error = locate_binaries(dir.path()).expect_err("missing winxd");
653        assert!(error.to_string().contains("winxd"), "{error}");
654    }
655
656    #[test]
657    fn replace_binary_swaps_the_file_and_leaves_no_staging_artifacts() {
658        let dir = tempfile::tempdir().expect("tempdir");
659        let source = dir.path().join("src.bin");
660        let destination = dir.path().join("bin").join("winxd");
661        std::fs::create_dir_all(destination.parent().expect("parent")).expect("mkdir");
662        std::fs::write(&destination, b"old").expect("write old");
663        std::fs::write(&source, b"new").expect("write new");
664        replace_binary(&source, &destination).expect("replace");
665        assert_eq!(std::fs::read(&destination).expect("read"), b"new");
666        let leftovers: Vec<String> = std::fs::read_dir(destination.parent().expect("parent"))
667            .expect("read dir")
668            .flatten()
669            .map(|entry| entry.file_name().to_string_lossy().into_owned())
670            .filter(|name| name != "winxd")
671            .collect();
672        assert!(leftovers.is_empty(), "unexpected files: {leftovers:?}");
673        #[cfg(unix)]
674        {
675            use std::os::unix::fs::PermissionsExt as _;
676            let mode = std::fs::metadata(&destination).expect("metadata").permissions().mode();
677            assert_eq!(mode & 0o111, 0o111, "installed binary must be executable");
678        }
679    }
680
681    #[test]
682    fn staging_uses_a_private_name_and_never_follows_a_planted_symlink() {
683        let dir = tempfile::tempdir().expect("tempdir");
684        let source = dir.path().join("src.bin");
685        std::fs::write(&source, b"new").expect("write new");
686        let destination = dir.path().join("winxd");
687        std::fs::write(&destination, b"old").expect("write old");
688        std::fs::write(dir.path().join("winxd.new"), b"planted").expect("foreign staging file");
689        let staged = stage_binary(&source, &destination).expect("stage");
690        assert_ne!(staged, dir.path().join("winxd.new"), "staging must not reuse a shared name");
691        assert_eq!(std::fs::read(&staged).expect("read staged"), b"new");
692        assert_eq!(std::fs::read(&destination).expect("read"), b"old", "target untouched");
693        commit_binary(&staged, &destination).expect("commit");
694        assert_eq!(std::fs::read(&destination).expect("read"), b"new");
695        assert_eq!(
696            std::fs::read(dir.path().join("winxd.new")).expect("foreign file"),
697            b"planted",
698            "someone else's staging file is left alone"
699        );
700    }
701
702    #[test]
703    fn only_one_update_runs_per_install_directory() {
704        let dir = tempfile::tempdir().expect("tempdir");
705        let first = InstallLock::acquire(dir.path()).expect("first lock");
706        let error = InstallLock::acquire(dir.path()).expect_err("second lock must fail");
707        assert!(error.to_string().contains("another update is running"), "{error}");
708        drop(first);
709        InstallLock::acquire(dir.path()).expect("lock is released with its holder");
710    }
711
712    #[test]
713    fn replace_binary_installs_into_an_empty_directory() {
714        let dir = tempfile::tempdir().expect("tempdir");
715        let source = dir.path().join("src.bin");
716        std::fs::write(&source, b"new").expect("write new");
717        let destination = dir.path().join("fresh");
718        replace_binary(&source, &destination).expect("replace");
719        assert_eq!(std::fs::read(&destination).expect("read"), b"new");
720    }
721
722    #[test]
723    fn platform_asset_is_known_for_supported_targets() {
724        let name = platform_asset_name();
725        if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
726            assert_eq!(name.expect("asset"), "winx-linux-amd64.tar.gz");
727        } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
728            assert_eq!(name.expect("asset"), "winx-windows-amd64.zip");
729        } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
730            assert_eq!(name.expect("asset"), "winx-macos-arm64.tar.gz");
731        }
732    }
733
734    #[test]
735    fn default_install_dir_is_the_running_executables_directory() {
736        let dir = default_install_dir().expect("install dir");
737        assert!(dir.is_dir(), "{}", dir.display());
738    }
739}