1use 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
24pub const REPOSITORY: &str = "gabrielmaialva33/winx-code-agent";
26pub const CHECKSUM_ASSET: &str = "SHA256SUMS";
28pub const BINARIES: [&str; 3] = ["winx-code-agent", "winxd", "winx-guardian"];
30
31#[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#[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 UpToDate,
64 UpdateAvailable,
66 Installed,
68}
69
70#[derive(Debug, Clone, Default)]
72pub struct UpdateOptions {
73 pub check_only: bool,
75 pub tag: Option<String>,
77 pub force: bool,
79 pub install_dir: Option<PathBuf>,
81}
82
83pub 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, ¤t_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 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 let _install_lock = InstallLock::acquire(&install_dir)?;
164 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
186fn should_install(explicit_tag: bool, newer: bool, same: bool) -> bool {
190 if explicit_tag {
191 !same
192 } else {
193 newer
194 }
195}
196
197pub 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
210pub 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
228pub 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
238pub 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
266pub 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
288pub 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
302pub 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
323pub 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
362pub fn replace_binary(source: &Path, destination: &Path) -> Result<()> {
365 let staged = stage_binary(source, destination)?;
366 commit_binary(&staged, destination)
367}
368
369#[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
400pub 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
451pub 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 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
495fn 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
527fn 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 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}