返回 CodeWhale
update.rs
根目录 / crates / cli / src / update.rs
1 //! Self-update for the `codewhale` binary.
2 //!
3 //! The `update` subcommand fetches the latest release from
4 //! `github.com/Hmbown/CodeWhale/releases/latest`, downloads the
5 //! platform-correct binary, verifies its SHA256 checksum, and atomically
6 //! replaces the currently running binary.
7
8 use std::cmp::Ordering;
9 use std::collections::HashMap;
10 #[cfg(target_os = "android")]
11 use std::ffi::CStr;
12 #[cfg(any(target_os = "android", all(test, unix)))]
13 use std::ffi::OsStr;
14 use std::path::{Path, PathBuf};
15
16 use anyhow::{Context, Result, anyhow, bail};
17 use codewhale_release::{
18 CHECKSUM_MANIFEST_ASSET, InstallMethod, ReleaseChannel, ReleaseQuery, UPDATE_USER_AGENT,
19 compare_release_versions, is_beta_tag, mirror_asset_url, resolve_release_query,
20 update_is_needed, update_network_fallback_hint,
21 };
22 use reqwest::Proxy;
23 use std::io::Write;
24 use std::time::Duration;
25
26 const GITHUB_LATEST_RELEASE_PAGE_URL: &str = "https://github.com/Hmbown/CodeWhale/releases/latest";
27 const GITHUB_RELEASE_DOWNLOAD_BASE_URL: &str =
28 "https://github.com/Hmbown/CodeWhale/releases/download";
29 const UPDATE_HTTP_ATTEMPTS: usize = 3;
30 const UPDATE_HTTP_RETRY_DELAY_MS: u64 = 100;
31 #[cfg(target_os = "android")]
32 const ANDROID_PROC_SELF_MAPS: &str = "/proc/self/maps";
33
34 /// Run the self-update workflow.
35 ///
36 /// OpenHarmony (HarmonyOS) won't compile this file, so no need to handle
37 pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Result<()> {
38 let executable_identity = update_executable_identity()?;
39 let current_exe = executable_identity.path.clone();
40 let legacy_binary = is_legacy_binary(&current_exe);
41 ensure_supported_release_target(std::env::consts::OS, std::env::consts::ARCH)?;
42
43 let targets = update_targets_for_exe(&current_exe);
44 let channel = ReleaseChannel::from_beta_flag(beta);
45 let current_version = env!("CARGO_PKG_VERSION");
46 let proxy = proxy_arg
47 .as_deref()
48 .map(validate_and_build_proxy)
49 .transpose()?;
50
51 println!("Checking for {} updates...", channel.label());
52 println!("Current binary: {}", current_exe.display());
53 println!("Current version: v{current_version}");
54 if legacy_binary {
55 println!();
56 println!("{}", legacy_binary_message(&current_exe));
57 }
58 if let Some(warning) = managed_install_warning(InstallMethod::detect(&current_exe)) {
59 println!();
60 println!("{warning}");
61 }
62
63 if check_only {
64 let latest_tag = latest_release_tag(channel, proxy.as_ref())
65 .with_context(update_network_fallback_hint)?;
66 println!("Latest {} release: {latest_tag}", channel.label());
67 if update_is_needed(channel, current_version, &latest_tag)? {
68 println!("Update available. Run `codewhale update` to install {latest_tag}.");
69 } else {
70 match compare_release_versions(current_version, &latest_tag)? {
71 Ordering::Greater => {
72 println!("Current build is newer than the latest published release.");
73 }
74 Ordering::Less | Ordering::Equal => {
75 println!("Already up to date.");
76 }
77 }
78 }
79 return Ok(());
80 }
81
82 // Step 1: Fetch latest release metadata
83 let fetched =
84 fetch_latest_release(channel, proxy.as_ref()).with_context(update_network_fallback_hint)?;
85 let release = &fetched.release;
86 let latest_tag = &release.tag_name;
87 println!("Latest {} release: {latest_tag}", channel.label());
88
89 if let UpdateReleaseSource::Mirror { base_url } = &fetched.source {
90 if channel == ReleaseChannel::Beta {
91 println!(
92 "Using release mirror {base_url}; --beta does not select GitHub beta releases in mirror mode."
93 );
94 }
95 } else if !update_is_needed(channel, current_version, latest_tag)? {
96 println!("Already up to date; no download needed.");
97 return Ok(());
98 }
99
100 // Step 2: Download the aggregated SHA256 checksum manifest if available
101 let checksum_manifest = match select_checksum_manifest_asset(release) {
102 Some(checksum_asset) => {
103 println!("Downloading {}...", checksum_asset.name);
104 let checksum_bytes = download_url(&checksum_asset.browser_download_url, proxy.as_ref())
105 .with_context(|| {
106 format!(
107 "failed to download {}\n{}",
108 checksum_asset.name,
109 update_network_fallback_hint()
110 )
111 })?;
112 let checksum_text = std::str::from_utf8(&checksum_bytes)
113 .with_context(|| format!("{} is not valid UTF-8", checksum_asset.name))?;
114 Some(parse_checksum_manifest(checksum_text)?)
115 }
116 None => {
117 println!(" (no SHA256 checksum manifest found; skipping verification)");
118 None
119 }
120 };
121
122 // Step 3: Download and verify every colocated binary in the install.
123 let mut downloads = Vec::new();
124 for target in &targets {
125 let asset = select_platform_asset(release, &target.asset_stem).with_context(|| {
126 format!(
127 "no asset found for platform {} in release {latest_tag}. \
128 Available assets: {}",
129 target.asset_stem,
130 release
131 .assets
132 .iter()
133 .map(|a| a.name.as_str())
134 .collect::<Vec<_>>()
135 .join(", ")
136 )
137 })?;
138
139 println!("Downloading {}...", asset.name);
140 let bytes =
141 download_url(&asset.browser_download_url, proxy.as_ref()).with_context(|| {
142 format!(
143 "failed to download {}\n{}",
144 asset.name,
145 update_network_fallback_hint()
146 )
147 })?;
148
149 if let Some(checksums) = &checksum_manifest {
150 let expected = checksums
151 .get(&asset.name)
152 .with_context(|| format!("checksum manifest is missing {}", asset.name))?;
153 let actual = sha256_hex(&bytes);
154 if !actual.eq_ignore_ascii_case(expected) {
155 bail!(
156 "SHA256 mismatch for {}!\n expected: {expected}\n actual: {actual}",
157 asset.name
158 );
159 }
160 }
161
162 preflight_downloaded_binary(&asset.name, &bytes)?;
163 downloads.push((target.path.clone(), asset.name.clone(), bytes));
164 }
165
166 if checksum_manifest.is_some() {
167 println!("SHA256 checksum verified.");
168 }
169
170 // Step 4: Replace binaries only after all downloads and the primary
171 // executable identity verify. The preflight happens before a colocated
172 // sibling can change, then the primary is checked again just in time.
173 replace_verified_downloads(&downloads, || {
174 validate_primary_update_identity(&executable_identity)
175 })?;
176
177 println!(
178 "\n✅ Successfully updated to {latest_tag}!\n\
179 Updated binaries:\n{}\n\
180 \n\
181 Restart the application to use the new version.",
182 downloads
183 .iter()
184 .map(|(path, asset, _)| format!(" - {} ({asset})", path.display()))
185 .collect::<Vec<_>>()
186 .join("\n")
187 );
188
189 Ok(())
190 }
191
192 /// Warn when self-update would overwrite a binary a package manager owns.
193 ///
194 /// We warn rather than refuse: the download still produces a working newer
195 /// binary, and refusing would break workflows that have been doing this for
196 /// releases. But the manager's metadata will then describe a version that is
197 /// no longer on disk, and its next upgrade silently reverts the user — so say
198 /// so, and name the command that would have done this properly.
199 fn managed_install_warning(method: InstallMethod) -> Option<String> {
200 if method.supports_self_update() {
201 return None;
202 }
203 Some(format!(
204 "Warning: this binary looks like a {label} install.\n \
205 `{command}` is the command that updates it cleanly.\n \
206 Self-updating in place still works, but leaves {label} describing a version\n \
207 that is no longer on disk, and its next upgrade will revert this update.",
208 label = method.label(),
209 command = method.update_command()
210 ))
211 }
212
213 /// Resolve the executable that the updater is allowed to replace.
214 ///
215 /// Android's `std::env::current_exe()`, `AT_EXECFN`, and `/proc/self/exe` can
216 /// all identify Bionic's runtime linker rather than the launched program. On
217 /// Android, locate a marker compiled into this executable with `dladdr`, then
218 /// require the executable `/proc/self/maps` row containing that same address
219 /// to agree by canonical path, device, and inode.
220 #[derive(Debug, Clone)]
221 struct UpdateExecutableIdentity {
222 path: PathBuf,
223 #[cfg(target_os = "android")]
224 android_proof: AndroidExecutableProof,
225 }
226
227 #[cfg(not(target_os = "android"))]
228 fn update_executable_identity() -> Result<UpdateExecutableIdentity> {
229 let path = std::env::current_exe().context("failed to determine current executable path")?;
230 Ok(UpdateExecutableIdentity { path })
231 }
232
233 #[cfg(target_os = "android")]
234 fn update_executable_identity() -> Result<UpdateExecutableIdentity> {
235 let android_proof = android_loaded_executable_proof()?;
236 Ok(UpdateExecutableIdentity {
237 path: android_proof.path.clone(),
238 android_proof,
239 })
240 }
241
242 #[cfg(target_os = "android")]
243 #[inline(never)]
244 extern "C" fn android_update_image_marker() -> usize {
245 android_update_image_marker as *const () as usize
246 }
247
248 #[cfg(target_os = "android")]
249 fn android_loaded_executable_proof() -> Result<AndroidExecutableProof> {
250 let marker = android_update_image_marker as *const () as usize as u64;
251 let dladdr_path = android_dladdr_path(android_update_image_marker as *const libc::c_void)?;
252 let maps = std::fs::read_to_string(ANDROID_PROC_SELF_MAPS)
253 .context("failed to read Android executable mappings from /proc/self/maps")?;
254 android_loaded_executable_proof_report(&maps, marker, &dladdr_path)
255 }
256
257 #[cfg(target_os = "android")]
258 fn android_dladdr_path(marker: *const libc::c_void) -> Result<PathBuf> {
259 use std::os::unix::ffi::OsStrExt;
260
261 let mut info = std::mem::MaybeUninit::<libc::Dl_info>::zeroed();
262 // SAFETY: `marker` points to a function in this loaded image and `info`
263 // points to writable storage for the duration of the call.
264 let found = unsafe { libc::dladdr(marker, info.as_mut_ptr()) };
265 if found == 0 {
266 bail!("Android dladdr could not locate the updater's loaded image");
267 }
268 // SAFETY: A non-zero dladdr result initializes `info`.
269 let info = unsafe { info.assume_init() };
270 if info.dli_fname.is_null() {
271 bail!("Android dladdr returned an empty loaded-image path");
272 }
273 // SAFETY: `dli_fname` is a NUL-terminated string owned by the dynamic
274 // loader and remains valid while this image is loaded.
275 let bytes = unsafe { CStr::from_ptr(info.dli_fname) }.to_bytes();
276 if bytes.is_empty() {
277 bail!("Android dladdr returned an empty loaded-image path");
278 }
279 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
280 }
281
282 #[cfg(any(target_os = "android", all(test, unix)))]
283 #[derive(Debug, Clone, PartialEq, Eq)]
284 struct AndroidImageMapping {
285 start: u64,
286 end: u64,
287 device_major: u32,
288 device_minor: u32,
289 inode: u64,
290 path: PathBuf,
291 }
292
293 #[cfg(any(target_os = "android", all(test, unix)))]
294 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
295 enum AndroidExecutableProofKind {
296 DladdrAndProcMaps,
297 }
298
299 #[cfg(any(target_os = "android", all(test, unix)))]
300 #[derive(Debug, Clone, PartialEq, Eq)]
301 struct AndroidExecutableProof {
302 path: PathBuf,
303 device_major: u32,
304 device_minor: u32,
305 inode: u64,
306 proof_kind: AndroidExecutableProofKind,
307 }
308
309 #[cfg(any(target_os = "android", all(test, unix)))]
310 fn parse_android_image_mapping(maps: &str, marker: u64) -> Result<AndroidImageMapping> {
311 let mut matching = None;
312 for (line_index, line) in maps.lines().enumerate() {
313 if line.trim().is_empty() {
314 continue;
315 }
316 let mut fields = line.split_whitespace();
317 let range = fields
318 .next()
319 .with_context(|| format!("malformed /proc/self/maps line {}", line_index + 1))?;
320 let (start, end) = range
321 .split_once('-')
322 .with_context(|| format!("malformed mapping range `{range}`"))?;
323 let start = u64::from_str_radix(start, 16)
324 .with_context(|| format!("invalid mapping start `{start}`"))?;
325 let end =
326 u64::from_str_radix(end, 16).with_context(|| format!("invalid mapping end `{end}`"))?;
327 if !(start <= marker && marker < end) {
328 continue;
329 }
330
331 let permissions = fields
332 .next()
333 .context("loaded-image mapping is missing permissions")?;
334 let _offset = fields
335 .next()
336 .context("loaded-image mapping is missing its file offset")?;
337 let device = fields
338 .next()
339 .context("loaded-image mapping is missing its device")?;
340 let inode = fields
341 .next()
342 .context("loaded-image mapping is missing its inode")?
343 .parse::<u64>()
344 .context("loaded-image mapping has an invalid inode")?;
345 let path = fields.collect::<Vec<_>>().join(" ");
346
347 if permissions.as_bytes().get(2) != Some(&b'x') {
348 bail!("loaded-image mapping for updater marker is not executable");
349 }
350 if inode == 0 {
351 bail!("loaded-image mapping for updater marker has no file inode");
352 }
353 let (device_major, device_minor) = device
354 .split_once(':')
355 .context("loaded-image mapping has an invalid device")?;
356 let device_major = u32::from_str_radix(device_major, 16)
357 .context("loaded-image mapping has an invalid device major number")?;
358 let device_minor = u32::from_str_radix(device_minor, 16)
359 .context("loaded-image mapping has an invalid device minor number")?;
360 if path.is_empty() {
361 bail!("loaded-image mapping for updater marker has no pathname");
362 }
363
364 let mapping = AndroidImageMapping {
365 start,
366 end,
367 device_major,
368 device_minor,
369 inode,
370 path: PathBuf::from(path),
371 };
372 if matching.replace(mapping).is_some() {
373 bail!("multiple /proc/self/maps rows contain the updater marker");
374 }
375 }
376
377 matching.ok_or_else(|| anyhow!("no /proc/self/maps row contains the updater marker"))
378 }
379
380 #[cfg(all(test, unix))]
381 fn resolve_android_loaded_executable_report(
382 maps: &str,
383 marker: u64,
384 dladdr_path: &Path,
385 ) -> Result<PathBuf> {
386 Ok(android_loaded_executable_proof_report(maps, marker, dladdr_path)?.path)
387 }
388
389 #[cfg(any(target_os = "android", all(test, unix)))]
390 fn android_loaded_executable_proof_report(
391 maps: &str,
392 marker: u64,
393 dladdr_path: &Path,
394 ) -> Result<AndroidExecutableProof> {
395 let mapping = parse_android_image_mapping(maps, marker)?;
396 validate_android_reported_path("dladdr", dladdr_path)?;
397 validate_android_reported_path("/proc/self/maps", &mapping.path)?;
398
399 let resolved_dladdr = dladdr_path.canonicalize().with_context(|| {
400 format!(
401 "failed to canonicalize Android dladdr path {}",
402 dladdr_path.display()
403 )
404 })?;
405 let resolved_mapping = mapping.path.canonicalize().with_context(|| {
406 format!(
407 "failed to canonicalize Android loaded-image mapping {}",
408 mapping.path.display()
409 )
410 })?;
411 if resolved_dladdr != resolved_mapping {
412 bail!(
413 "Android loaded-image authorities disagree: dladdr resolved to {}, but /proc/self/maps resolved to {}",
414 resolved_dladdr.display(),
415 resolved_mapping.display()
416 );
417 }
418 if is_android_linker_name(&resolved_mapping) {
419 bail!(
420 "Android loaded-image authorities resolved to runtime linker {}; refusing to use the linker as an update target",
421 resolved_mapping.display()
422 );
423 }
424 if !is_executable_file(&resolved_mapping) {
425 bail!(
426 "Android loaded image `{}` is not an executable regular file; refusing to select an update target",
427 resolved_mapping.display()
428 );
429 }
430
431 validate_android_mapping_identity(&mapping, &resolved_mapping)?;
432 Ok(AndroidExecutableProof {
433 path: resolved_mapping,
434 device_major: mapping.device_major,
435 device_minor: mapping.device_minor,
436 inode: mapping.inode,
437 proof_kind: AndroidExecutableProofKind::DladdrAndProcMaps,
438 })
439 }
440
441 #[cfg(any(target_os = "android", all(test, unix)))]
442 fn validate_android_reported_path(authority: &str, path: &Path) -> Result<()> {
443 if !path.is_absolute() {
444 bail!(
445 "Android {authority} reported non-absolute loaded-image path `{}`",
446 path.display()
447 );
448 }
449 if path.to_string_lossy().ends_with(" (deleted)") {
450 bail!(
451 "Android {authority} reported deleted loaded image `{}`",
452 path.display()
453 );
454 }
455 if is_android_linker_name(path) {
456 bail!(
457 "Android {authority} identifies runtime linker `{}`; refusing to use the linker as an update target",
458 path.display()
459 );
460 }
461 Ok(())
462 }
463
464 #[cfg(any(target_os = "android", all(test, unix)))]
465 fn validate_android_mapping_identity(
466 mapping: &AndroidImageMapping,
467 candidate: &Path,
468 ) -> Result<()> {
469 use std::os::unix::fs::MetadataExt;
470
471 let candidate_metadata = std::fs::metadata(candidate).with_context(|| {
472 format!(
473 "failed to stat Android update target {}",
474 candidate.display()
475 )
476 })?;
477 let (candidate_major, candidate_minor) = android_device_parts(candidate_metadata.dev());
478 let identity_matches = mapping.device_major == candidate_major
479 && mapping.device_minor == candidate_minor
480 && mapping.inode == candidate_metadata.ino();
481 if !identity_matches {
482 bail!(
483 "Android loaded-image identity changed: /proc/self/maps has device/inode {:x}:{:x}:{}, but update target {} is {:x}:{:x}:{}; refusing to replace it",
484 mapping.device_major,
485 mapping.device_minor,
486 mapping.inode,
487 candidate.display(),
488 candidate_major,
489 candidate_minor,
490 candidate_metadata.ino()
491 );
492 }
493 Ok(())
494 }
495
496 #[cfg(any(target_os = "android", all(test, unix)))]
497 fn android_device_parts(device: u64) -> (u32, u32) {
498 // Linux/Bionic's dev_t encoding, matching makedev(3), major(3), and
499 // minor(3). `/proc/self/maps` renders these components in hexadecimal.
500 let major = ((device >> 8) & 0xfff) as u32;
501 let minor = ((device & 0xff) | ((device >> 12) & 0xfff00)) as u32;
502 (major, minor)
503 }
504
505 fn validate_primary_update_identity(identity: &UpdateExecutableIdentity) -> Result<()> {
506 #[cfg(target_os = "android")]
507 {
508 let fresh = android_loaded_executable_proof()?;
509 if fresh != identity.android_proof {
510 bail!(
511 "Android loaded-image proof changed from {:?} to {:?}; refusing to replace the update target",
512 identity.android_proof,
513 fresh
514 );
515 }
516 return Ok(());
517 }
518
519 #[cfg(not(target_os = "android"))]
520 {
521 let _ = identity;
522 Ok(())
523 }
524 }
525
526 fn replace_verified_downloads<F>(
527 downloads: &[(PathBuf, String, Vec<u8>)],
528 validate_primary_identity: F,
529 ) -> Result<()>
530 where
531 F: Fn() -> Result<()>,
532 {
533 // Fail before mutating a sibling if the primary pathname no longer names
534 // the process image that initiated this update.
535 validate_primary_identity()?;
536 for (path, _, bytes) in downloads.iter().rev() {
537 replace_binary_with_validation(path, bytes, || {
538 // Re-check after each temp file is fully staged and immediately
539 // before every destructive rename. This protects paired installs
540 // before the sibling as well as just in time for the primary.
541 validate_primary_identity()
542 })?;
543 }
544 Ok(())
545 }
546
547 #[cfg(any(target_os = "android", all(test, unix)))]
548 fn is_android_linker_name(path: &Path) -> bool {
549 path.file_name()
550 .and_then(OsStr::to_str)
551 .is_some_and(|name| {
552 matches!(
553 name,
554 "linker"
555 | "linker64"
556 | "linker_asan"
557 | "linker_asan64"
558 | "linker_hwasan"
559 | "linker_hwasan64"
560 )
561 })
562 }
563
564 #[cfg(any(target_os = "android", all(test, unix)))]
565 fn is_executable_file(path: &Path) -> bool {
566 let Ok(metadata) = std::fs::metadata(path) else {
567 return false;
568 };
569 if !metadata.is_file() {
570 return false;
571 }
572
573 #[cfg(unix)]
574 {
575 use std::os::unix::fs::PermissionsExt;
576 metadata.permissions().mode() & 0o111 != 0
577 }
578
579 #[cfg(not(unix))]
580 {
581 true
582 }
583 }
584
585 #[derive(Debug, Clone, PartialEq, Eq)]
586 struct FetchedRelease {
587 release: Release,
588 source: UpdateReleaseSource,
589 }
590
591 #[derive(Debug, Clone, PartialEq, Eq)]
592 enum UpdateReleaseSource {
593 GitHub,
594 Mirror { base_url: String },
595 }
596
597 fn ensure_supported_release_target(os: &str, arch: &str) -> Result<()> {
598 if os == "linux" && arch == "riscv64" {
599 bail!(
600 "Linux riscv64 release assets are temporarily unavailable because \
601 rquickjs-sys 0.12.0 does not ship riscv64gc-unknown-linux-gnu bindings. \
602 See docs/INSTALL.md for the current platform matrix."
603 );
604 }
605 Ok(())
606 }
607
608 pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str {
609 match arch {
610 "aarch64" => "arm64",
611 "x86_64" => "x64",
612 other => other,
613 }
614 }
615
616 /// Returns true when the binary name belongs to the pre-rebrand `deepseek-tui` era.
617 pub(crate) fn is_legacy_binary(current_exe: &Path) -> bool {
618 let exe_name = current_exe
619 .file_name()
620 .and_then(|name| name.to_str())
621 .unwrap_or("")
622 .to_ascii_lowercase();
623 exe_name.starts_with("deepseek")
624 }
625
626 fn legacy_binary_message(current_exe: &Path) -> String {
627 format!(
628 "\
629 this binary ({exe}) is using the legacy deepseek/deepseek-tui command name.
630
631 The package has been renamed to `codewhale`. This update will install canonical
632 Codewhale binaries (`codewhale` and, when present, `codewhale-tui`) beside the
633 legacy command when the install directory is writable. DeepSeek provider support
634 is unchanged.
635
636 If this update cannot write to the install directory, reinstall using your
637 original install method:
638
639 npm:
640 npm uninstall -g deepseek-tui
641 npm install -g codewhale
642
643 Cargo:
644 cargo uninstall deepseek-tui-cli 2>/dev/null || true
645 cargo uninstall deepseek-tui 2>/dev/null || true
646 cargo install codewhale-cli --locked
647 cargo install codewhale-tui --locked
648
649 Homebrew:
650 brew upgrade deepseek-tui
651
652 Manual binary:
653 download the matched codewhale and codewhale-tui assets from
654 https://github.com/Hmbown/CodeWhale/releases/latest
655
656 Once `codewhale` is on your PATH, run `codewhale update` for future updates.",
657 exe = current_exe.display(),
658 )
659 }
660
661 pub(crate) fn binary_prefix_for_exe(current_exe: &Path) -> &'static str {
662 let exe_name = current_exe
663 .file_name()
664 .and_then(|name| name.to_str())
665 .unwrap_or("codewhale")
666 .to_ascii_lowercase();
667 if exe_name.contains("codewhale-tui") || exe_name.contains("deepseek-tui") {
668 "codewhale-tui"
669 } else {
670 "codewhale"
671 }
672 }
673
674 fn sibling_prefix_for(prefix: &str) -> &'static str {
675 if prefix == "codewhale-tui" {
676 "codewhale"
677 } else {
678 "codewhale-tui"
679 }
680 }
681
682 fn sibling_binary_path(current_exe: &Path, sibling_prefix: &str) -> PathBuf {
683 current_exe.with_file_name(format!("{sibling_prefix}{}", std::env::consts::EXE_SUFFIX))
684 }
685
686 fn canonical_binary_path_for_prefix(current_exe: &Path, prefix: &str) -> PathBuf {
687 if is_legacy_binary(current_exe) {
688 current_exe.with_file_name(format!("{prefix}{}", std::env::consts::EXE_SUFFIX))
689 } else {
690 current_exe.to_path_buf()
691 }
692 }
693
694 fn legacy_binary_name_for_prefix(prefix: &str) -> &'static str {
695 if prefix == "codewhale-tui" {
696 "deepseek-tui"
697 } else {
698 "deepseek"
699 }
700 }
701
702 fn legacy_sibling_binary_path(current_exe: &Path, sibling_prefix: &str) -> PathBuf {
703 current_exe.with_file_name(format!(
704 "{}{}",
705 legacy_binary_name_for_prefix(sibling_prefix),
706 std::env::consts::EXE_SUFFIX
707 ))
708 }
709
710 fn should_update_sibling(
711 current_exe: &Path,
712 canonical_sibling: &Path,
713 sibling_prefix: &str,
714 ) -> bool {
715 canonical_sibling.exists()
716 || (is_legacy_binary(current_exe)
717 && legacy_sibling_binary_path(current_exe, sibling_prefix).exists())
718 }
719
720 #[derive(Debug, Clone, PartialEq, Eq)]
721 struct UpdateTarget {
722 path: PathBuf,
723 asset_stem: String,
724 }
725
726 fn update_targets_for_exe(current_exe: &Path) -> Vec<UpdateTarget> {
727 let current_prefix = binary_prefix_for_exe(current_exe);
728 let mut targets = vec![UpdateTarget {
729 path: canonical_binary_path_for_prefix(current_exe, current_prefix),
730 asset_stem: release_asset_stem_for_prefix(
731 current_prefix,
732 std::env::consts::OS,
733 std::env::consts::ARCH,
734 ),
735 }];
736
737 let sibling_prefix = sibling_prefix_for(current_prefix);
738 let sibling = sibling_binary_path(current_exe, sibling_prefix);
739 if should_update_sibling(current_exe, &sibling, sibling_prefix) {
740 targets.push(UpdateTarget {
741 path: sibling,
742 asset_stem: release_asset_stem_for_prefix(
743 sibling_prefix,
744 std::env::consts::OS,
745 std::env::consts::ARCH,
746 ),
747 });
748 }
749
750 targets
751 }
752
753 fn release_asset_stem_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
754 let arch = release_arch_for_rust_arch(rust_arch);
755 format!("{prefix}-{os}-{arch}")
756 }
757
758 fn release_asset_name_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
759 let stem = release_asset_stem_for_prefix(prefix, os, rust_arch);
760 if os == "windows" {
761 format!("{stem}.exe")
762 } else {
763 stem
764 }
765 }
766
767 #[cfg(test)]
768 fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String {
769 let prefix = binary_prefix_for_exe(current_exe);
770 release_asset_stem_for_prefix(prefix, os, rust_arch)
771 }
772
773 pub(crate) fn asset_matches_platform(asset_name: &str, binary_name: &str) -> bool {
774 if asset_name.ends_with(".sha256") {
775 return false;
776 }
777 asset_name == binary_name
778 || asset_name == format!("{binary_name}.exe")
779 || asset_name.starts_with(&format!("{binary_name}."))
780 }
781
782 fn asset_is_exact_platform_binary(asset_name: &str, binary_name: &str) -> bool {
783 asset_name == binary_name || asset_name == format!("{binary_name}.exe")
784 }
785
786 fn select_platform_asset<'a>(release: &'a Release, binary_name: &str) -> Option<&'a Asset> {
787 release
788 .assets
789 .iter()
790 .find(|asset| asset_is_exact_platform_binary(&asset.name, binary_name))
791 .or_else(|| {
792 release
793 .assets
794 .iter()
795 .find(|asset| asset_matches_platform(&asset.name, binary_name))
796 })
797 }
798
799 fn select_checksum_manifest_asset(release: &Release) -> Option<&Asset> {
800 release
801 .assets
802 .iter()
803 .find(|asset| asset.name == CHECKSUM_MANIFEST_ASSET)
804 }
805
806 fn parse_checksum_manifest(text: &str) -> Result<HashMap<String, String>> {
807 let mut checksums = HashMap::new();
808
809 for (index, line) in text.lines().enumerate() {
810 let trimmed = line.trim();
811 if trimmed.is_empty() {
812 continue;
813 }
814
815 if trimmed.len() < 66 {
816 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
817 }
818
819 let (hash, rest) = trimmed.split_at(64);
820 if !hash.chars().all(|ch| ch.is_ascii_hexdigit())
821 || rest.is_empty()
822 || !rest.chars().next().is_some_and(char::is_whitespace)
823 {
824 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
825 }
826
827 let mut asset_name = rest.trim_start();
828 if let Some(stripped) = asset_name.strip_prefix('*') {
829 asset_name = stripped;
830 }
831 if asset_name.is_empty() {
832 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
833 }
834
835 checksums.insert(asset_name.to_string(), hash.to_ascii_lowercase());
836 }
837
838 Ok(checksums)
839 }
840
841 #[cfg(test)]
842 fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result<String> {
843 let checksums = parse_checksum_manifest(text)?;
844 checksums
845 .get(asset_name)
846 .cloned()
847 .with_context(|| format!("checksum manifest is missing {asset_name}"))
848 }
849
850 /// GitHub release metadata.
851 #[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
852 struct Release {
853 tag_name: String,
854 #[serde(default)]
855 prerelease: bool,
856 assets: Vec<Asset>,
857 }
858
859 /// A single release asset.
860 #[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
861 struct Asset {
862 name: String,
863 browser_download_url: String,
864 }
865
866 /// Validate the proxy URL format and build a proxy for update HTTP requests.
867 pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result<Proxy> {
868 let proxy_url = reqwest::Url::parse(proxy_str).with_context(|| {
869 format!(
870 "invalid proxy URL: {proxy_str}\n\
871 Expected format: http://host:port, https://host:port, or socks5://host:port"
872 )
873 })?;
874 Proxy::all(proxy_url).context("failed to configure update proxy")
875 }
876
877 fn update_http_client(proxy: Option<&Proxy>) -> Result<reqwest::blocking::Client> {
878 let mut builder = codewhale_release::platform_blocking_http_client_builder();
879 if let Some(proxy) = proxy {
880 builder = builder.proxy(proxy.clone());
881 }
882 builder
883 .user_agent(UPDATE_USER_AGENT)
884 .timeout(Duration::from_secs(5 * 60))
885 .build()
886 .context("failed to build update HTTP client")
887 }
888
889 fn latest_release_tag(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result<String> {
890 let FetchedRelease { release, .. } = fetch_latest_release(channel, proxy)?;
891 Ok(release.tag_name)
892 }
893
894 /// Fetch the latest release metadata from GitHub.
895 fn fetch_latest_release(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result<FetchedRelease> {
896 match resolve_release_query(channel) {
897 ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease {
898 release: release_from_mirror_base_url(
899 &base_url,
900 &version,
901 std::env::consts::OS,
902 std::env::consts::ARCH,
903 ),
904 source: UpdateReleaseSource::Mirror { base_url },
905 }),
906 ReleaseQuery::GitHubLatest { url } => match fetch_latest_release_from_url(url, proxy) {
907 Ok(release) => Ok(FetchedRelease {
908 release,
909 source: UpdateReleaseSource::GitHub,
910 }),
911 Err(api_error) => {
912 eprintln!(
913 "GitHub API release lookup failed; trying github.com releases/latest fallback..."
914 );
915 Ok(FetchedRelease {
916 release: fetch_latest_stable_release_from_redirect(proxy).with_context(
917 || format!("GitHub API release lookup failed first: {api_error:#}"),
918 )?,
919 source: UpdateReleaseSource::GitHub,
920 })
921 }
922 },
923 ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease {
924 release: fetch_latest_beta_release_from_url(url, proxy)?,
925 source: UpdateReleaseSource::GitHub,
926 }),
927 }
928 }
929
930 fn release_from_mirror_base_url(
931 base_url: &str,
932 version: &str,
933 os: &str,
934 rust_arch: &str,
935 ) -> Release {
936 let tag_name = format!("v{}", version.trim_start_matches('v'));
937 release_from_asset_base_url(&tag_name, base_url, os, rust_arch)
938 }
939
940 fn release_from_github_download_tag(tag_name: &str, os: &str, rust_arch: &str) -> Release {
941 let tag_name = format!("v{}", tag_name.trim_start_matches('v'));
942 let base_url = format!("{GITHUB_RELEASE_DOWNLOAD_BASE_URL}/{tag_name}");
943 release_from_asset_base_url(&tag_name, &base_url, os, rust_arch)
944 }
945
946 fn release_from_asset_base_url(
947 tag_name: &str,
948 base_url: &str,
949 os: &str,
950 rust_arch: &str,
951 ) -> Release {
952 let mut assets = vec![Asset {
953 name: CHECKSUM_MANIFEST_ASSET.to_string(),
954 browser_download_url: mirror_asset_url(base_url, CHECKSUM_MANIFEST_ASSET),
955 }];
956
957 for prefix in ["codewhale", "codewhale-tui"] {
958 let name = release_asset_name_for_prefix(prefix, os, rust_arch);
959 assets.push(Asset {
960 browser_download_url: mirror_asset_url(base_url, &name),
961 name,
962 });
963 }
964
965 Release {
966 tag_name: tag_name.to_string(),
967 prerelease: false,
968 assets,
969 }
970 }
971
972 fn fetch_release_json_once(
973 url: &str,
974 description: &str,
975 proxy: Option<&Proxy>,
976 ) -> Result<(reqwest::StatusCode, String)> {
977 let client = update_http_client(proxy)?;
978 let response = client
979 .get(url)
980 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
981 .send()
982 .with_context(|| format!("failed to fetch {description} from {url}"))?;
983 let status = response.status();
984 let body = response
985 .text()
986 .with_context(|| format!("failed to read {description} response body from {url}"))?;
987 Ok((status, body))
988 }
989
990 fn fetch_release_json(url: &str, description: &str, proxy: Option<&Proxy>) -> Result<String> {
991 let mut last_error = None;
992 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
993 match fetch_release_json_once(url, description, proxy) {
994 Ok((status, body)) if status.is_success() => return Ok(body),
995 Ok((status, body)) => {
996 let error =
997 anyhow!("failed to fetch {description} from {url}: HTTP {status}\n{body}");
998 if should_retry_http_status(status) && attempt < UPDATE_HTTP_ATTEMPTS {
999 last_error = Some(error);
1000 sleep_before_update_retry(attempt);
1001 continue;
1002 }
1003 return Err(error);
1004 }
1005 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1006 last_error = Some(error);
1007 sleep_before_update_retry(attempt);
1008 }
1009 Err(error) => return Err(error),
1010 }
1011 }
1012 Err(last_error.unwrap_or_else(|| anyhow!("failed to fetch {description} from {url}")))
1013 }
1014
1015 fn should_retry_http_status(status: reqwest::StatusCode) -> bool {
1016 status.is_server_error()
1017 || status == reqwest::StatusCode::REQUEST_TIMEOUT
1018 || status == reqwest::StatusCode::TOO_MANY_REQUESTS
1019 }
1020
1021 fn sleep_before_update_retry(attempt: usize) {
1022 std::thread::sleep(Duration::from_millis(
1023 UPDATE_HTTP_RETRY_DELAY_MS * attempt as u64,
1024 ));
1025 }
1026
1027 fn fetch_latest_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result<Release> {
1028 let body = fetch_release_json(url, "release info", proxy)?;
1029 let release: Release = serde_json::from_str(&body).with_context(|| {
1030 format!("failed to parse release JSON from GitHub API. Response: {body}")
1031 })?;
1032
1033 Ok(release)
1034 }
1035
1036 fn fetch_latest_stable_release_from_redirect(proxy: Option<&Proxy>) -> Result<Release> {
1037 let tag_name =
1038 fetch_latest_stable_tag_from_redirect_url(GITHUB_LATEST_RELEASE_PAGE_URL, proxy)?;
1039 Ok(release_from_github_download_tag(
1040 &tag_name,
1041 std::env::consts::OS,
1042 std::env::consts::ARCH,
1043 ))
1044 }
1045
1046 fn fetch_latest_stable_tag_from_redirect_url(url: &str, proxy: Option<&Proxy>) -> Result<String> {
1047 let client = update_http_client(proxy)?;
1048 let mut last_error = None;
1049 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
1050 match fetch_latest_stable_tag_from_redirect_url_once(&client, url) {
1051 Ok(tag_name) => return Ok(tag_name),
1052 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1053 last_error = Some(error);
1054 sleep_before_update_retry(attempt);
1055 }
1056 Err(error) => return Err(error),
1057 }
1058 }
1059 Err(last_error.unwrap_or_else(|| anyhow!("failed to resolve latest stable release from {url}")))
1060 }
1061
1062 fn fetch_latest_stable_tag_from_redirect_url_once(
1063 client: &reqwest::blocking::Client,
1064 url: &str,
1065 ) -> Result<String> {
1066 let response = client
1067 .get(url)
1068 .send()
1069 .with_context(|| format!("failed to fetch release redirect from {url}"))?;
1070 let status = response.status();
1071 let final_url = response.url().clone();
1072 if status.is_success() {
1073 if let Some(tag_name) = release_tag_from_github_release_url(&final_url) {
1074 return Ok(tag_name);
1075 }
1076 let body = response
1077 .text()
1078 .with_context(|| format!("failed to read release redirect response from {url}"))?;
1079 if let Some(tag_name) = release_tag_from_github_release_html(&body) {
1080 return Ok(tag_name);
1081 }
1082 bail!("release redirect did not resolve to a tag URL: {final_url}");
1083 }
1084
1085 let body = response
1086 .text()
1087 .with_context(|| format!("failed to read release redirect response from {url}"))?;
1088 bail!("failed to fetch release redirect from {url}: HTTP {status}\n{body}");
1089 }
1090
1091 fn release_tag_from_github_release_url(url: &reqwest::Url) -> Option<String> {
1092 let segments = url.path_segments()?.collect::<Vec<_>>();
1093 segments
1094 .windows(3)
1095 .find(|window| window[0] == "releases" && window[1] == "tag")
1096 .map(|window| window[2].to_string())
1097 .filter(|tag| !tag.is_empty())
1098 }
1099
1100 fn release_tag_from_github_release_html(body: &str) -> Option<String> {
1101 const MARKERS: &[&str] = &[
1102 "/Hmbown/CodeWhale/releases/tag/",
1103 "/hmbown/CodeWhale/releases/tag/",
1104 "/releases/tag/",
1105 ];
1106 for marker in MARKERS {
1107 for rest in body.split(marker).skip(1) {
1108 let tag = rest
1109 .split(['"', '\'', '<', '>', '?', '#', '&'])
1110 .next()
1111 .unwrap_or("")
1112 .trim();
1113 if !tag.is_empty() {
1114 return Some(tag.to_string());
1115 }
1116 }
1117 }
1118 None
1119 }
1120
1121 fn fetch_latest_beta_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result<Release> {
1122 let body = fetch_release_json(url, "release list", proxy)?;
1123 // GitHub caps this endpoint at 100 releases per page. Codewhale uses the
1124 // first page as the latest-beta search window, matching GitHub's ordering.
1125 let releases: Vec<Release> = serde_json::from_str(&body).with_context(|| {
1126 format!("failed to parse release list JSON from GitHub API. Response: {body}")
1127 })?;
1128
1129 releases
1130 .into_iter()
1131 .find(|release| is_beta_tag(&release.tag_name))
1132 .context("no beta release found in GitHub releases")
1133 }
1134
1135 /// Download a URL to bytes.
1136 fn download_url(url: &str, proxy: Option<&Proxy>) -> Result<Vec<u8>> {
1137 let mut last_error = None;
1138 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
1139 match download_url_once(url, proxy) {
1140 Ok((status, bytes)) if status.is_success() => return Ok(bytes),
1141 Ok((status, bytes)) => {
1142 let body = String::from_utf8_lossy(&bytes);
1143 let error = anyhow!("download failed with HTTP {status}: {body}");
1144 if should_retry_http_status(status) && attempt < UPDATE_HTTP_ATTEMPTS {
1145 last_error = Some(error);
1146 sleep_before_update_retry(attempt);
1147 continue;
1148 }
1149 return Err(error);
1150 }
1151 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1152 last_error = Some(error);
1153 sleep_before_update_retry(attempt);
1154 }
1155 Err(error) => return Err(error),
1156 }
1157 }
1158 Err(last_error.unwrap_or_else(|| anyhow!("failed to download {url}")))
1159 }
1160
1161 fn download_url_once(url: &str, proxy: Option<&Proxy>) -> Result<(reqwest::StatusCode, Vec<u8>)> {
1162 let client = update_http_client(proxy)?;
1163 let response = client
1164 .get(url)
1165 .send()
1166 .with_context(|| format!("failed to download {url}"))?;
1167 let status = response.status();
1168 let bytes = response
1169 .bytes()
1170 .with_context(|| format!("failed to read response body from {url}"))?;
1171
1172 Ok((status, bytes.to_vec()))
1173 }
1174
1175 /// Compute the SHA256 hex digest of data.
1176 fn sha256_hex(data: &[u8]) -> String {
1177 use sha2::Digest;
1178 let hash = sha2::Sha256::digest(data);
1179 hex_bytes(hash)
1180 }
1181
1182 fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
1183 let bytes = bytes.as_ref();
1184 let mut out = String::with_capacity(bytes.len() * 2);
1185 for byte in bytes {
1186 use std::fmt::Write as _;
1187 let _ = write!(&mut out, "{byte:02x}");
1188 }
1189 out
1190 }
1191
1192 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1193 struct GlibcVersion {
1194 major: u32,
1195 minor: u32,
1196 patch: u32,
1197 }
1198
1199 impl GlibcVersion {
1200 fn new(major: u32, minor: u32, patch: u32) -> Self {
1201 Self {
1202 major,
1203 minor,
1204 patch,
1205 }
1206 }
1207
1208 fn display(self) -> String {
1209 if self.patch == 0 {
1210 format!("{}.{}", self.major, self.minor)
1211 } else {
1212 format!("{}.{}.{}", self.major, self.minor, self.patch)
1213 }
1214 }
1215 }
1216
1217 fn parse_glibc_version(text: &str) -> Option<GlibcVersion> {
1218 text.split(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
1219 .filter(|part| part.contains('.'))
1220 .find_map(parse_glibc_version_token)
1221 }
1222
1223 fn parse_glibc_version_token(token: &str) -> Option<GlibcVersion> {
1224 let mut parts = token.split('.');
1225 let major = parts.next()?.parse().ok()?;
1226 let minor = parts.next()?.parse().ok()?;
1227 let patch = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0);
1228 Some(GlibcVersion::new(major, minor, patch))
1229 }
1230
1231 fn highest_required_glibc(bytes: &[u8]) -> Option<GlibcVersion> {
1232 const MARKER: &[u8] = b"GLIBC_";
1233 let mut offset = 0;
1234 let mut highest = None;
1235
1236 while let Some(found) = find_bytes(&bytes[offset..], MARKER) {
1237 let start = offset + found + MARKER.len();
1238 let mut end = start;
1239 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
1240 end += 1;
1241 }
1242 if end > start
1243 && let Ok(token) = std::str::from_utf8(&bytes[start..end])
1244 && let Some(version) = parse_glibc_version_token(token)
1245 && highest.is_none_or(|current| version > current)
1246 {
1247 highest = Some(version);
1248 }
1249 offset = start;
1250 }
1251
1252 highest
1253 }
1254
1255 fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1256 if needle.is_empty() || haystack.len() < needle.len() {
1257 return None;
1258 }
1259 haystack
1260 .windows(needle.len())
1261 .position(|window| window == needle)
1262 }
1263
1264 fn glibc_check_disabled() -> bool {
1265 [
1266 "CODEWHALE_SKIP_GLIBC_CHECK",
1267 "DEEPSEEK_TUI_SKIP_GLIBC_CHECK",
1268 "DEEPSEEK_SKIP_GLIBC_CHECK",
1269 ]
1270 .into_iter()
1271 .any(|name| std::env::var_os(name).is_some_and(|value| value == std::ffi::OsStr::new("1")))
1272 }
1273
1274 fn preflight_downloaded_binary(asset_name: &str, bytes: &[u8]) -> Result<()> {
1275 // GNU libc preflight is Linux-only (#4241). Rust treats `target_os = "android"`
1276 // as distinct from `"linux"`, so Termux/Android builds skip this check entirely
1277 // — Android uses Bionic libc, not glibc.
1278 if !cfg!(target_os = "linux") || glibc_check_disabled() {
1279 return Ok(());
1280 }
1281
1282 let Some(required) = highest_required_glibc(bytes) else {
1283 return Ok(());
1284 };
1285 let host = detect_host_glibc();
1286 if host.is_some_and(|host| host >= required) {
1287 return Ok(());
1288 }
1289
1290 bail!(
1291 "{}",
1292 glibc_compatibility_message(asset_name, required, host)
1293 );
1294 }
1295
1296 fn detect_host_glibc() -> Option<GlibcVersion> {
1297 let getconf = std::process::Command::new("getconf")
1298 .arg("GNU_LIBC_VERSION")
1299 .output()
1300 .ok()
1301 .filter(|output| output.status.success())
1302 .and_then(|output| String::from_utf8(output.stdout).ok())
1303 .and_then(|output| parse_glibc_version(&output));
1304 if getconf.is_some() {
1305 return getconf;
1306 }
1307
1308 std::process::Command::new("ldd")
1309 .arg("--version")
1310 .output()
1311 .ok()
1312 .filter(|output| output.status.success())
1313 .and_then(|output| {
1314 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1315 if text.trim().is_empty() {
1316 text = String::from_utf8_lossy(&output.stderr).to_string();
1317 }
1318 parse_glibc_version(&text)
1319 })
1320 }
1321
1322 fn glibc_compatibility_message(
1323 asset_name: &str,
1324 required: GlibcVersion,
1325 host: Option<GlibcVersion>,
1326 ) -> String {
1327 let host_line = match host {
1328 Some(host) => format!(
1329 "this system has glibc {}, which is too old for that asset.",
1330 host.display()
1331 ),
1332 None => "this system does not appear to provide GNU libc.".to_string(),
1333 };
1334 format!(
1335 "\
1336 Prebuilt Codewhale asset `{asset_name}` requires GLIBC_{required}, but {host_line}
1337
1338 Official Linux release binaries are GNU libc builds. Ubuntu 22.04 ships glibc
1339 2.35, so it cannot run a binary that was built against Ubuntu 24.04/glibc 2.39.
1340
1341 Install from source on this host instead:
1342
1343 cargo install codewhale-cli --locked
1344 cargo install codewhale-tui --locked
1345
1346 Release engineering follow-up: build Linux GNU assets against an older glibc
1347 baseline, or add a musl/static Linux asset. Set CODEWHALE_SKIP_GLIBC_CHECK=1 to
1348 bypass this preflight at your own risk.",
1349 required = required.display(),
1350 )
1351 }
1352
1353 /// Replace the running binary.
1354 ///
1355 /// Writes the new binary to a secure temp file in the target directory, then
1356 /// installs it in place. Unix can atomically replace the executable path. On
1357 /// Windows, replacing a running executable can fail, so rename the current file
1358 /// out of the way before moving the new binary into the original path.
1359 #[cfg(test)]
1360 fn replace_binary(target: &Path, new_bytes: &[u8]) -> Result<()> {
1361 replace_binary_with_validation(target, new_bytes, || Ok(()))
1362 }
1363
1364 fn replace_binary_with_validation<F>(
1365 target: &Path,
1366 new_bytes: &[u8],
1367 validate_before_replace: F,
1368 ) -> Result<()>
1369 where
1370 F: FnOnce() -> Result<()>,
1371 {
1372 let parent = target
1373 .parent()
1374 .filter(|path| !path.as_os_str().is_empty())
1375 .unwrap_or_else(|| Path::new("."));
1376
1377 let mut tmp = tempfile::Builder::new()
1378 .prefix(".codewhale-update-")
1379 .tempfile_in(parent)
1380 .with_context(|| format!("failed to create temp file in {}", parent.display()))?;
1381 tmp.write_all(new_bytes)
1382 .with_context(|| format!("failed to write temp file at {}", tmp.path().display()))?;
1383
1384 // Preserve permissions from the original binary (if it exists)
1385 if target.exists() {
1386 if let Ok(meta) = std::fs::metadata(target) {
1387 let _ = std::fs::set_permissions(tmp.path(), meta.permissions());
1388 }
1389 } else {
1390 #[cfg(unix)]
1391 {
1392 use std::os::unix::fs::PermissionsExt;
1393 let _ = std::fs::set_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755));
1394 }
1395 }
1396
1397 validate_before_replace()?;
1398
1399 #[cfg(windows)]
1400 {
1401 let backup = backup_path_for(target);
1402 if target.exists() {
1403 std::fs::rename(target, &backup).with_context(|| {
1404 format!(
1405 "failed to move current executable {} to {}",
1406 target.display(),
1407 backup.display()
1408 )
1409 })?;
1410 }
1411
1412 if let Err(err) = tmp.persist(target) {
1413 if backup.exists() {
1414 let _ = std::fs::rename(&backup, target);
1415 }
1416 bail!(
1417 "failed to install new binary at {}: {}",
1418 target.display(),
1419 err.error
1420 );
1421 }
1422
1423 let _ = std::fs::remove_file(&backup);
1424 }
1425
1426 #[cfg(not(windows))]
1427 {
1428 tmp.persist(target)
1429 .map_err(|err| err.error)
1430 .with_context(|| format!("failed to rename temp file to {}", target.display()))?;
1431 }
1432
1433 Ok(())
1434 }
1435
1436 #[cfg(windows)]
1437 fn backup_path_for(target: &Path) -> std::path::PathBuf {
1438 let pid = std::process::id();
1439 for index in 0..100 {
1440 let mut candidate = target.to_path_buf();
1441 let suffix = if index == 0 {
1442 format!("old-{pid}")
1443 } else {
1444 format!("old-{pid}-{index}")
1445 };
1446 candidate.set_extension(suffix);
1447 if !candidate.exists() {
1448 return candidate;
1449 }
1450 }
1451 target.with_extension(format!("old-{pid}-fallback"))
1452 }
1453
1454 #[cfg(test)]
1455 mod tests {
1456 use super::*;
1457 use std::io::{Read, Write};
1458 use std::net::TcpListener;
1459 use std::sync::mpsc;
1460 use std::thread;
1461
1462 #[cfg(unix)]
1463 fn write_test_executable(path: &Path) {
1464 std::fs::write(path, b"test executable").unwrap();
1465 use std::os::unix::fs::PermissionsExt;
1466 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
1467 }
1468
1469 /// Verify the arch mapping used when constructing asset names.
1470 /// The mapping must use release-asset naming (arm64/x64), not Rust
1471 /// stdlib constants (aarch64/x86_64).
1472 #[test]
1473 fn test_arch_mapping() {
1474 assert_eq!(release_arch_for_rust_arch("aarch64"), "arm64");
1475 assert_eq!(release_arch_for_rust_arch("x86_64"), "x64");
1476 // Pass-through for unknown arches
1477 assert_eq!(release_arch_for_rust_arch("riscv64"), "riscv64");
1478 // The currently-compiled arch maps to a release asset name
1479 let compiled_arch = std::env::consts::ARCH;
1480 let asset_arch = release_arch_for_rust_arch(compiled_arch);
1481 // Must not contain the raw Rust constant names
1482 assert!(
1483 !asset_arch.contains("aarch64") && !asset_arch.contains("x86_64"),
1484 "asset arch '{asset_arch}' still uses raw Rust constant name"
1485 );
1486 }
1487
1488 #[test]
1489 fn linux_riscv64_update_is_explicitly_unsupported() {
1490 let err = ensure_supported_release_target("linux", "riscv64")
1491 .expect_err("linux riscv64 should not claim a release asset");
1492 let message = err.to_string();
1493 assert!(message.contains("Linux riscv64 release assets are temporarily unavailable"));
1494 assert!(message.contains("rquickjs-sys 0.12.0"));
1495 ensure_supported_release_target("linux", "aarch64").unwrap();
1496 ensure_supported_release_target("macos", "aarch64").unwrap();
1497 }
1498
1499 #[cfg(unix)]
1500 const TEST_ANDROID_MARKER: u64 = 0x1800;
1501
1502 #[cfg(unix)]
1503 fn test_android_mapping_line(path: &Path, permissions: &str) -> String {
1504 use std::os::unix::fs::MetadataExt;
1505
1506 let metadata = std::fs::metadata(path).unwrap();
1507 let (device_major, device_minor) = android_device_parts(metadata.dev());
1508 format!(
1509 "1000-2000 {permissions} 00000000 {:x}:{:x} {} {}\n",
1510 device_major,
1511 device_minor,
1512 metadata.ino(),
1513 path.display()
1514 )
1515 }
1516
1517 #[cfg(unix)]
1518 #[test]
1519 fn android_loaded_image_resolves_agreed_mapping() {
1520 let dir = tempfile::TempDir::new().unwrap();
1521 let executable = dir.path().join("codewhale");
1522 write_test_executable(&executable);
1523 let maps = test_android_mapping_line(&executable, "r-xp");
1524
1525 let resolved =
1526 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
1527 .unwrap();
1528
1529 assert_eq!(resolved, executable.canonicalize().unwrap());
1530 assert_eq!(update_targets_for_exe(&resolved)[0].path, resolved);
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn android_loaded_image_canonicalizes_symlink_and_sibling_policy() {
1536 use std::os::unix::fs::symlink;
1537
1538 let dir = tempfile::TempDir::new().unwrap();
1539 let canonical_dir = dir.path().join("canonical");
1540 let install_dir = dir.path().join("install");
1541 std::fs::create_dir(&canonical_dir).unwrap();
1542 std::fs::create_dir(&install_dir).unwrap();
1543 let canonical_dispatcher = canonical_dir.join("codewhale");
1544 let canonical_tui = canonical_dir.join("codewhale-tui");
1545 let invoked = install_dir.join("codewhale");
1546 write_test_executable(&canonical_dispatcher);
1547 write_test_executable(&canonical_tui);
1548 symlink(&canonical_dispatcher, &invoked).unwrap();
1549 let maps = test_android_mapping_line(&invoked, "r-xp");
1550
1551 let resolved =
1552 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &invoked).unwrap();
1553 let target_paths = update_targets_for_exe(&resolved)
1554 .into_iter()
1555 .map(|target| target.path)
1556 .collect::<Vec<_>>();
1557
1558 assert_eq!(
1559 target_paths,
1560 vec![
1561 canonical_dispatcher.canonicalize().unwrap(),
1562 canonical_tui.canonicalize().unwrap()
1563 ]
1564 );
1565 assert!(!target_paths.contains(&invoked));
1566 }
1567
1568 #[cfg(unix)]
1569 #[test]
1570 fn android_loaded_image_requires_marker_mapping() {
1571 let dir = tempfile::TempDir::new().unwrap();
1572 let executable = dir.path().join("codewhale");
1573 write_test_executable(&executable);
1574 let maps = test_android_mapping_line(&executable, "r-xp");
1575
1576 let error = resolve_android_loaded_executable_report(&maps, 0x3000, &executable)
1577 .expect_err("a marker outside every mapping must fail closed");
1578
1579 assert!(
1580 error.to_string().contains("no /proc/self/maps row"),
1581 "unexpected error: {error:#}"
1582 );
1583 }
1584
1585 #[cfg(unix)]
1586 #[test]
1587 fn android_loaded_image_requires_executable_mapping() {
1588 let dir = tempfile::TempDir::new().unwrap();
1589 let executable = dir.path().join("codewhale");
1590 write_test_executable(&executable);
1591 let maps = test_android_mapping_line(&executable, "rw-p");
1592
1593 let error =
1594 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
1595 .expect_err("a non-executable marker mapping must fail closed");
1596
1597 assert!(
1598 error
1599 .to_string()
1600 .contains("mapping for updater marker is not executable"),
1601 "unexpected error: {error:#}"
1602 );
1603 }
1604
1605 #[cfg(unix)]
1606 #[test]
1607 fn android_loaded_image_rejects_anonymous_mapping() {
1608 let dir = tempfile::TempDir::new().unwrap();
1609 let executable = dir.path().join("codewhale");
1610 write_test_executable(&executable);
1611 let maps = "1000-2000 r-xp 00000000 00:00 0\n";
1612
1613 let error =
1614 resolve_android_loaded_executable_report(maps, TEST_ANDROID_MARKER, &executable)
1615 .expect_err("an anonymous marker mapping must fail closed");
1616
1617 assert!(
1618 error.to_string().contains("has no file inode"),
1619 "unexpected error: {error:#}"
1620 );
1621 }
1622
1623 #[cfg(unix)]
1624 #[test]
1625 fn android_loaded_image_rejects_relative_or_deleted_paths() {
1626 let dir = tempfile::TempDir::new().unwrap();
1627 let executable = dir.path().join("codewhale");
1628 write_test_executable(&executable);
1629 let metadata = std::fs::metadata(&executable).unwrap();
1630 use std::os::unix::fs::MetadataExt;
1631 let (device_major, device_minor) = android_device_parts(metadata.dev());
1632 let relative_maps = format!(
1633 "1000-2000 r-xp 00000000 {:x}:{:x} {} codewhale\n",
1634 device_major,
1635 device_minor,
1636 metadata.ino()
1637 );
1638 let deleted = PathBuf::from(format!("{} (deleted)", executable.display()));
1639
1640 let relative_error = resolve_android_loaded_executable_report(
1641 &relative_maps,
1642 TEST_ANDROID_MARKER,
1643 &executable,
1644 )
1645 .expect_err("a relative maps pathname must fail closed");
1646 let deleted_error = resolve_android_loaded_executable_report(
1647 &test_android_mapping_line(&executable, "r-xp"),
1648 TEST_ANDROID_MARKER,
1649 &deleted,
1650 )
1651 .expect_err("a deleted dladdr pathname must fail closed");
1652
1653 assert!(relative_error.to_string().contains("non-absolute"));
1654 assert!(deleted_error.to_string().contains("deleted loaded image"));
1655 }
1656
1657 #[cfg(unix)]
1658 #[test]
1659 fn android_loaded_image_rejects_linker_and_symlink_to_linker() {
1660 use std::os::unix::fs::symlink;
1661
1662 let dir = tempfile::TempDir::new().unwrap();
1663 let runtime_linker = dir.path().join("linker64");
1664 let invoked = dir.path().join("codewhale");
1665 write_test_executable(&runtime_linker);
1666 symlink(&runtime_linker, &invoked).unwrap();
1667 let maps = test_android_mapping_line(&invoked, "r-xp");
1668
1669 let direct_error = resolve_android_loaded_executable_report(
1670 &maps,
1671 TEST_ANDROID_MARKER,
1672 Path::new("/system/bin/linker64"),
1673 )
1674 .expect_err("a directly reported Bionic linker must fail closed");
1675 let symlink_error =
1676 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &invoked)
1677 .expect_err("a symlink to a linker must fail closed");
1678
1679 assert!(
1680 direct_error
1681 .to_string()
1682 .contains("identifies runtime linker")
1683 );
1684 assert!(
1685 symlink_error
1686 .to_string()
1687 .contains("resolved to runtime linker")
1688 );
1689 }
1690
1691 #[cfg(unix)]
1692 #[test]
1693 fn android_linker_name_recognizes_bionic_loader_variants() {
1694 for name in [
1695 "linker",
1696 "linker64",
1697 "linker_asan",
1698 "linker_asan64",
1699 "linker_hwasan",
1700 "linker_hwasan64",
1701 ] {
1702 assert!(
1703 is_android_linker_name(
1704 Path::new("/apex/com.android.runtime/bin")
1705 .join(name)
1706 .as_path()
1707 ),
1708 "{name} must never become an updater target"
1709 );
1710 }
1711 assert!(!is_android_linker_name(Path::new("codewhale")));
1712 }
1713
1714 #[cfg(unix)]
1715 #[test]
1716 fn android_loaded_image_rejects_authority_disagreement() {
1717 let dir = tempfile::TempDir::new().unwrap();
1718 let mapped = dir.path().join("mapped-codewhale");
1719 let dladdr = dir.path().join("dladdr-codewhale");
1720 write_test_executable(&mapped);
1721 write_test_executable(&dladdr);
1722 let maps = test_android_mapping_line(&mapped, "r-xp");
1723
1724 let error = resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &dladdr)
1725 .expect_err("dladdr and maps path disagreement must fail closed");
1726
1727 assert!(
1728 error.to_string().contains("authorities disagree"),
1729 "unexpected error: {error:#}"
1730 );
1731 }
1732
1733 #[cfg(unix)]
1734 #[test]
1735 fn android_loaded_image_rejects_non_executable_file() {
1736 let dir = tempfile::TempDir::new().unwrap();
1737 let executable = dir.path().join("codewhale");
1738 std::fs::write(&executable, b"not executable").unwrap();
1739 let maps = test_android_mapping_line(&executable, "r-xp");
1740
1741 let error =
1742 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
1743 .expect_err("a non-executable target file must fail closed");
1744
1745 assert!(
1746 error.to_string().contains("not an executable regular file"),
1747 "unexpected error: {error:#}"
1748 );
1749 }
1750
1751 #[cfg(unix)]
1752 #[test]
1753 fn android_loaded_image_rejects_device_inode_mismatch() {
1754 let dir = tempfile::TempDir::new().unwrap();
1755 let executable = dir.path().join("codewhale");
1756 write_test_executable(&executable);
1757 let metadata = std::fs::metadata(&executable).unwrap();
1758 use std::os::unix::fs::MetadataExt;
1759 let (device_major, device_minor) = android_device_parts(metadata.dev());
1760 let maps = format!(
1761 "1000-2000 r-xp 00000000 {:x}:{:x} {} {}\n",
1762 device_major,
1763 device_minor,
1764 metadata.ino() + 1,
1765 executable.display()
1766 );
1767
1768 let error =
1769 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
1770 .expect_err("a different maps device/inode must fail closed");
1771
1772 assert!(
1773 error.to_string().contains("loaded-image identity changed"),
1774 "unexpected error: {error:#}"
1775 );
1776 }
1777
1778 #[cfg(unix)]
1779 #[test]
1780 fn android_loaded_image_recheck_detects_pre_replace_swap() {
1781 let dir = tempfile::TempDir::new().unwrap();
1782 let candidate = dir.path().join("codewhale");
1783 let replacement = dir.path().join("replacement");
1784 write_test_executable(&candidate);
1785 let maps = test_android_mapping_line(&candidate, "r-xp");
1786
1787 write_test_executable(&replacement);
1788 std::fs::rename(&replacement, &candidate).unwrap();
1789 let error =
1790 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &candidate)
1791 .expect_err("a path swap after download must fail before replacement");
1792
1793 assert!(
1794 error.to_string().contains("loaded-image identity changed"),
1795 "unexpected error: {error:#}"
1796 );
1797 }
1798
1799 #[cfg(unix)]
1800 #[test]
1801 fn android_identity_preflight_prevents_all_paired_replacements() {
1802 let dir = tempfile::TempDir::new().unwrap();
1803 let primary = dir.path().join("codewhale");
1804 let sibling = dir.path().join("codewhale-tui");
1805 let swapped_primary = dir.path().join("swapped-primary");
1806
1807 write_test_executable(&primary);
1808 std::fs::write(&primary, b"original running primary").unwrap();
1809 let maps = test_android_mapping_line(&primary, "r-xp");
1810 write_test_executable(&sibling);
1811 std::fs::write(&sibling, b"original sibling").unwrap();
1812 write_test_executable(&swapped_primary);
1813 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
1814 std::fs::rename(&swapped_primary, &primary).unwrap();
1815
1816 let downloads = vec![
1817 (
1818 primary.clone(),
1819 "codewhale-android-arm64".to_string(),
1820 b"downloaded primary".to_vec(),
1821 ),
1822 (
1823 sibling.clone(),
1824 "codewhale-tui-android-arm64".to_string(),
1825 b"downloaded sibling".to_vec(),
1826 ),
1827 ];
1828 let error = replace_verified_downloads(&downloads, || {
1829 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
1830 .map(|_| ())
1831 })
1832 .expect_err("identity mismatch must fail before either binary changes");
1833
1834 assert!(
1835 error.to_string().contains("loaded-image identity changed"),
1836 "unexpected error: {error:#}"
1837 );
1838 assert_eq!(
1839 std::fs::read(&primary).unwrap(),
1840 b"externally swapped primary"
1841 );
1842 assert_eq!(std::fs::read(&sibling).unwrap(), b"original sibling");
1843 }
1844
1845 #[cfg(unix)]
1846 #[test]
1847 fn android_identity_recheck_before_sibling_prevents_pair_split() {
1848 use std::cell::Cell;
1849
1850 let dir = tempfile::TempDir::new().unwrap();
1851 let primary = dir.path().join("codewhale");
1852 let sibling = dir.path().join("codewhale-tui");
1853 let swapped_primary = dir.path().join("swapped-primary");
1854 write_test_executable(&primary);
1855 std::fs::write(&primary, b"original running primary").unwrap();
1856 let maps = test_android_mapping_line(&primary, "r-xp");
1857 write_test_executable(&sibling);
1858 std::fs::write(&sibling, b"original sibling").unwrap();
1859 write_test_executable(&swapped_primary);
1860 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
1861
1862 let downloads = vec![
1863 (
1864 primary.clone(),
1865 "codewhale-android-arm64".to_string(),
1866 b"downloaded primary".to_vec(),
1867 ),
1868 (
1869 sibling.clone(),
1870 "codewhale-tui-android-arm64".to_string(),
1871 b"downloaded sibling".to_vec(),
1872 ),
1873 ];
1874 let validation_calls = Cell::new(0);
1875 let error = replace_verified_downloads(&downloads, || {
1876 let call = validation_calls.get() + 1;
1877 validation_calls.set(call);
1878 if call == 1 {
1879 return Ok(());
1880 }
1881 std::fs::rename(&swapped_primary, &primary).unwrap();
1882 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
1883 .map(|_| ())
1884 })
1885 .expect_err("identity mismatch must fail before the staged sibling persists");
1886
1887 assert_eq!(validation_calls.get(), 2);
1888 assert!(
1889 error.to_string().contains("loaded-image identity changed"),
1890 "unexpected error: {error:#}"
1891 );
1892 assert_eq!(
1893 std::fs::read(&primary).unwrap(),
1894 b"externally swapped primary"
1895 );
1896 assert_eq!(std::fs::read(&sibling).unwrap(), b"original sibling");
1897 }
1898
1899 #[cfg(unix)]
1900 #[test]
1901 fn android_identity_jit_recheck_runs_after_staging_before_persist() {
1902 use std::cell::Cell;
1903
1904 let dir = tempfile::TempDir::new().unwrap();
1905 let primary = dir.path().join("codewhale");
1906 let swapped_primary = dir.path().join("swapped-primary");
1907 write_test_executable(&primary);
1908 std::fs::write(&primary, b"original running primary").unwrap();
1909 let maps = test_android_mapping_line(&primary, "r-xp");
1910 write_test_executable(&swapped_primary);
1911 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
1912
1913 let downloads = vec![(
1914 primary.clone(),
1915 "codewhale-android-arm64".to_string(),
1916 b"downloaded primary".to_vec(),
1917 )];
1918 let validation_calls = Cell::new(0);
1919 let error = replace_verified_downloads(&downloads, || {
1920 let call = validation_calls.get() + 1;
1921 validation_calls.set(call);
1922 if call == 1 {
1923 return Ok(());
1924 }
1925 std::fs::rename(&swapped_primary, &primary).unwrap();
1926 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
1927 .map(|_| ())
1928 })
1929 .expect_err("the post-staging identity swap must fail before persist");
1930
1931 assert_eq!(validation_calls.get(), 2);
1932 assert!(
1933 error.to_string().contains("loaded-image identity changed"),
1934 "unexpected error: {error:#}"
1935 );
1936 assert_eq!(
1937 std::fs::read(&primary).unwrap(),
1938 b"externally swapped primary"
1939 );
1940 assert!(
1941 std::fs::read_dir(dir.path()).unwrap().all(|entry| {
1942 !entry
1943 .unwrap()
1944 .file_name()
1945 .to_string_lossy()
1946 .starts_with(".codewhale-update-")
1947 }),
1948 "failed validation must clean the staged temp file"
1949 );
1950 }
1951
1952 /// Verify binary prefix detection for dispatcher vs TUI binary.
1953 #[test]
1954 fn test_binary_prefix_detection() {
1955 // TUI binary should use codewhale-tui prefix
1956 assert_eq!(
1957 binary_prefix_for_exe(Path::new("codewhale-tui")),
1958 "codewhale-tui"
1959 );
1960 assert_eq!(
1961 binary_prefix_for_exe(Path::new("codewhale-tui.exe")),
1962 "codewhale-tui"
1963 );
1964 assert_eq!(
1965 binary_prefix_for_exe(Path::new("CodeWhale-TUI.exe")),
1966 "codewhale-tui"
1967 );
1968 assert_eq!(
1969 binary_prefix_for_exe(Path::new("/usr/local/bin/codewhale-tui")),
1970 "codewhale-tui"
1971 );
1972
1973 // Dispatcher binary should use codewhale prefix
1974 assert_eq!(binary_prefix_for_exe(Path::new("codewhale")), "codewhale");
1975 assert_eq!(
1976 binary_prefix_for_exe(Path::new("codewhale.exe")),
1977 "codewhale"
1978 );
1979 assert_eq!(
1980 binary_prefix_for_exe(Path::new("/usr/local/bin/codewhale")),
1981 "codewhale"
1982 );
1983
1984 // Fallback for unknown names
1985 assert_eq!(
1986 binary_prefix_for_exe(Path::new("other-binary")),
1987 "codewhale"
1988 );
1989
1990 // Legacy names still map to the canonical update asset prefixes.
1991 assert_eq!(
1992 binary_prefix_for_exe(Path::new("deepseek-tui")),
1993 "codewhale-tui"
1994 );
1995 assert_eq!(
1996 binary_prefix_for_exe(Path::new("/usr/local/bin/deepseek-tui")),
1997 "codewhale-tui"
1998 );
1999 assert_eq!(
2000 binary_prefix_for_exe(Path::new("DeepSeek-TUI.exe")),
2001 "codewhale-tui"
2002 );
2003 assert_eq!(binary_prefix_for_exe(Path::new("deepseek")), "codewhale");
2004 }
2005
2006 #[test]
2007 fn test_is_legacy_binary_detection() {
2008 assert!(is_legacy_binary(Path::new("deepseek")));
2009 assert!(is_legacy_binary(Path::new("deepseek-tui")));
2010 assert!(is_legacy_binary(Path::new("/usr/local/bin/deepseek")));
2011 assert!(is_legacy_binary(Path::new("/usr/local/bin/deepseek-tui")));
2012 assert!(is_legacy_binary(Path::new("DeepSeek.exe")));
2013 assert!(is_legacy_binary(Path::new("DeepSeek-TUI.exe")));
2014 assert!(!is_legacy_binary(Path::new("codewhale")));
2015 assert!(!is_legacy_binary(Path::new("codewhale-tui")));
2016 assert!(!is_legacy_binary(Path::new("codew")));
2017 }
2018
2019 #[test]
2020 fn managed_installs_are_warned_before_self_update_overwrites_them() {
2021 let npm = managed_install_warning(InstallMethod::Npm).expect("npm is package-managed");
2022 assert!(npm.contains("npm install -g codewhale@latest"));
2023 assert!(npm.contains("revert this update"));
2024
2025 let brew =
2026 managed_install_warning(InstallMethod::Homebrew).expect("brew is package-managed");
2027 assert!(brew.contains("brew upgrade deepseek-tui"));
2028
2029 assert!(managed_install_warning(InstallMethod::Cargo).is_some());
2030
2031 // A plain release binary is exactly what this updater is for.
2032 assert!(managed_install_warning(InstallMethod::Binary).is_none());
2033 }
2034
2035 #[test]
2036 fn legacy_binary_message_gives_copy_pasteable_migration_steps() {
2037 let message = legacy_binary_message(Path::new("/usr/local/bin/deepseek-tui"));
2038
2039 assert!(message.contains("legacy deepseek/deepseek-tui command name"));
2040 assert!(message.contains("install canonical"));
2041 assert!(message.contains("DeepSeek provider support"));
2042 assert!(message.contains("is unchanged"));
2043 assert!(message.contains("npm uninstall -g deepseek-tui"));
2044 assert!(message.contains("npm install -g codewhale"));
2045 assert!(message.contains("cargo uninstall deepseek-tui-cli 2>/dev/null || true"));
2046 assert!(message.contains("cargo uninstall deepseek-tui 2>/dev/null || true"));
2047 assert!(message.contains("cargo install codewhale-cli --locked"));
2048 assert!(message.contains("cargo install codewhale-tui --locked"));
2049 assert!(message.contains("brew upgrade deepseek-tui"));
2050 assert!(message.contains("https://github.com/Hmbown/CodeWhale/releases/latest"));
2051 }
2052
2053 #[test]
2054 fn legacy_dispatcher_update_targets_canonical_codewhale_pair() {
2055 let dir = tempfile::TempDir::new().unwrap();
2056 let dispatcher = dir
2057 .path()
2058 .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX));
2059 let tui = dir
2060 .path()
2061 .join(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2062 std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
2063 std::fs::write(&tui, b"legacy tui").unwrap();
2064
2065 let targets = update_targets_for_exe(&dispatcher);
2066 let paths = targets
2067 .iter()
2068 .map(|target| target.path.clone())
2069 .collect::<Vec<_>>();
2070
2071 assert_eq!(
2072 paths,
2073 vec![
2074 dir.path()
2075 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)),
2076 dir.path()
2077 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX))
2078 ]
2079 );
2080 assert!(targets[0].asset_stem.starts_with("codewhale-"));
2081 assert!(targets[1].asset_stem.starts_with("codewhale-tui-"));
2082 }
2083
2084 #[test]
2085 fn legacy_tui_update_targets_canonical_tui_pair() {
2086 let dir = tempfile::TempDir::new().unwrap();
2087 let dispatcher = dir
2088 .path()
2089 .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX));
2090 let tui = dir
2091 .path()
2092 .join(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2093 std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
2094 std::fs::write(&tui, b"legacy tui").unwrap();
2095
2096 let targets = update_targets_for_exe(&tui);
2097 let paths = targets
2098 .iter()
2099 .map(|target| target.path.clone())
2100 .collect::<Vec<_>>();
2101
2102 assert_eq!(
2103 paths,
2104 vec![
2105 dir.path()
2106 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)),
2107 dir.path()
2108 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX))
2109 ]
2110 );
2111 assert!(targets[0].asset_stem.starts_with("codewhale-tui-"));
2112 assert!(targets[1].asset_stem.starts_with("codewhale-"));
2113 }
2114
2115 #[test]
2116 fn test_release_asset_stem_for_supported_platforms() {
2117 let cases = [
2118 ("codewhale", "macos", "aarch64", "codewhale-macos-arm64"),
2119 ("codewhale", "macos", "x86_64", "codewhale-macos-x64"),
2120 ("codewhale", "linux", "x86_64", "codewhale-linux-x64"),
2121 ("codewhale", "windows", "x86_64", "codewhale-windows-x64"),
2122 ("codewhale", "windows", "aarch64", "codewhale-windows-arm64"),
2123 (
2124 "codewhale-tui",
2125 "macos",
2126 "aarch64",
2127 "codewhale-tui-macos-arm64",
2128 ),
2129 (
2130 "codewhale-tui",
2131 "linux",
2132 "x86_64",
2133 "codewhale-tui-linux-x64",
2134 ),
2135 ];
2136
2137 for (exe, os, arch, expected) in cases {
2138 assert_eq!(release_asset_stem_for(Path::new(exe), os, arch), expected);
2139 }
2140 }
2141
2142 #[test]
2143 fn update_targets_include_existing_sibling_tui_for_dispatcher() {
2144 let dir = tempfile::TempDir::new().unwrap();
2145 let dispatcher = dir
2146 .path()
2147 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2148 let tui = dir
2149 .path()
2150 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2151 std::fs::write(&dispatcher, b"dispatcher").unwrap();
2152 std::fs::write(&tui, b"tui").unwrap();
2153
2154 let targets = update_targets_for_exe(&dispatcher);
2155 let paths = targets
2156 .iter()
2157 .map(|target| target.path.as_path())
2158 .collect::<Vec<_>>();
2159
2160 assert_eq!(paths, vec![dispatcher.as_path(), tui.as_path()]);
2161 assert!(targets[0].asset_stem.starts_with("codewhale-"));
2162 assert!(targets[1].asset_stem.starts_with("codewhale-tui-"));
2163 }
2164
2165 #[test]
2166 fn update_targets_skip_missing_sibling() {
2167 let dir = tempfile::TempDir::new().unwrap();
2168 let dispatcher = dir
2169 .path()
2170 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2171 std::fs::write(&dispatcher, b"dispatcher").unwrap();
2172
2173 let targets = update_targets_for_exe(&dispatcher);
2174
2175 assert_eq!(targets.len(), 1);
2176 assert_eq!(targets[0].path, dispatcher);
2177 assert!(targets[0].asset_stem.starts_with("codewhale-"));
2178 }
2179
2180 #[test]
2181 fn test_asset_matching_accepts_binary_assets_and_rejects_checksums() {
2182 assert!(asset_matches_platform(
2183 "codewhale-macos-arm64",
2184 "codewhale-macos-arm64"
2185 ));
2186 assert!(asset_matches_platform(
2187 "codewhale-macos-arm64.tar.gz",
2188 "codewhale-macos-arm64"
2189 ));
2190 assert!(asset_matches_platform(
2191 "codewhale-tui-windows-x64.exe",
2192 "codewhale-tui-windows-x64"
2193 ));
2194 assert!(!asset_matches_platform(
2195 "codewhale-tui-windows-x64.exe.sha256",
2196 "codewhale-tui-windows-x64"
2197 ));
2198 assert!(!asset_matches_platform(
2199 "codewhale-macos-aarch64.tar.gz",
2200 "codewhale-macos-arm64"
2201 ));
2202 }
2203
2204 #[test]
2205 fn select_platform_asset_prefers_bare_binary_over_archive() {
2206 let release = Release {
2207 tag_name: "v0.8.8".to_string(),
2208 prerelease: false,
2209 assets: vec![
2210 Asset {
2211 name: "codewhale-macos-arm64.tar.gz".to_string(),
2212 browser_download_url: "https://example.invalid/codewhale-macos-arm64.tar.gz"
2213 .to_string(),
2214 },
2215 Asset {
2216 name: "codewhale-macos-arm64".to_string(),
2217 browser_download_url: "https://example.invalid/codewhale-macos-arm64"
2218 .to_string(),
2219 },
2220 ],
2221 };
2222
2223 let asset =
2224 select_platform_asset(&release, "codewhale-macos-arm64").expect("platform asset");
2225
2226 assert_eq!(asset.name, "codewhale-macos-arm64");
2227 }
2228
2229 #[test]
2230 fn select_platform_asset_falls_back_to_archive_when_bare_binary_is_missing() {
2231 let release = Release {
2232 tag_name: "v0.8.8".to_string(),
2233 prerelease: false,
2234 assets: vec![Asset {
2235 name: "codewhale-macos-arm64.tar.gz".to_string(),
2236 browser_download_url: "https://example.invalid/codewhale-macos-arm64.tar.gz"
2237 .to_string(),
2238 }],
2239 };
2240
2241 let asset =
2242 select_platform_asset(&release, "codewhale-macos-arm64").expect("platform asset");
2243
2244 assert_eq!(asset.name, "codewhale-macos-arm64.tar.gz");
2245 }
2246
2247 #[test]
2248 fn test_sha256_hex_known_value() {
2249 let data = b"hello";
2250 let hash = sha256_hex(data);
2251 assert_eq!(
2252 hash,
2253 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_sha256_hex_empty() {
2259 let hash = sha256_hex(b"");
2260 assert_eq!(
2261 hash,
2262 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2263 );
2264 }
2265
2266 #[test]
2267 fn glibc_version_parser_reads_getconf_and_symbol_text() {
2268 assert_eq!(
2269 parse_glibc_version("glibc 2.35\n"),
2270 Some(GlibcVersion::new(2, 35, 0))
2271 );
2272 assert_eq!(
2273 parse_glibc_version("requires GLIBC_2.39"),
2274 Some(GlibcVersion::new(2, 39, 0))
2275 );
2276 assert_eq!(parse_glibc_version("not glibc"), None);
2277 }
2278
2279 #[test]
2280 fn highest_required_glibc_finds_highest_binary_symbol() {
2281 let bytes = b"\0GLIBC_2.17\0other\0GLIBC_2.39\0GLIBC_2.35";
2282
2283 assert_eq!(
2284 highest_required_glibc(bytes),
2285 Some(GlibcVersion::new(2, 39, 0))
2286 );
2287 }
2288
2289 #[test]
2290 fn glibc_compatibility_message_is_codewhale_branded_and_actionable() {
2291 let message = glibc_compatibility_message(
2292 "codewhale-linux-x64",
2293 GlibcVersion::new(2, 39, 0),
2294 Some(GlibcVersion::new(2, 35, 0)),
2295 );
2296
2297 assert!(message.contains("Prebuilt Codewhale asset `codewhale-linux-x64`"));
2298 assert!(message.contains("requires GLIBC_2.39"));
2299 assert!(message.contains("this system has glibc 2.35"));
2300 assert!(message.contains("cargo install codewhale-cli --locked"));
2301 assert!(message.contains("build Linux GNU assets against an older glibc"));
2302 }
2303
2304 #[test]
2305 fn parse_checksum_manifest_accepts_sha256sum_format() {
2306 let manifest = "\
2307 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 codewhale-macos-arm64
2308 E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-windows-x64.exe
2309 ";
2310 let checksums = parse_checksum_manifest(manifest).expect("valid manifest");
2311
2312 assert_eq!(
2313 checksums.get("codewhale-macos-arm64").map(String::as_str),
2314 Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
2315 );
2316 assert_eq!(
2317 checksums
2318 .get("codewhale-windows-x64.exe")
2319 .map(String::as_str),
2320 Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2321 );
2322 }
2323
2324 #[test]
2325 fn parse_checksum_manifest_rejects_malformed_lines() {
2326 let err = parse_checksum_manifest("not-a-hash codewhale-macos-arm64")
2327 .expect_err("invalid manifest line should fail");
2328 assert!(
2329 err.to_string().contains("invalid SHA256 manifest line"),
2330 "unexpected error: {err:#}"
2331 );
2332 }
2333
2334 #[test]
2335 fn expected_sha256_from_manifest_requires_matching_asset() {
2336 let manifest =
2337 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 other-asset\n";
2338 let err = expected_sha256_from_manifest(manifest, "codewhale-macos-arm64")
2339 .expect_err("missing asset should fail");
2340 assert!(
2341 err.to_string()
2342 .contains("checksum manifest is missing codewhale-macos-arm64"),
2343 "unexpected error: {err:#}"
2344 );
2345 }
2346
2347 #[test]
2348 fn test_replace_binary_creates_and_replaces() {
2349 let dir = tempfile::TempDir::new().unwrap();
2350 let target = dir.path().join("codewhale-test");
2351 // Write initial content
2352 std::fs::write(&target, b"old binary").unwrap();
2353
2354 replace_binary(&target, b"new binary content").unwrap();
2355 let content = std::fs::read_to_string(&target).unwrap();
2356 assert_eq!(content, "new binary content");
2357 }
2358
2359 #[test]
2360 fn test_replace_binary_creates_new_file() {
2361 let dir = tempfile::TempDir::new().unwrap();
2362 let target = dir.path().join("codewhale-new-test");
2363
2364 replace_binary(&target, b"fresh binary").unwrap();
2365 let content = std::fs::read_to_string(&target).unwrap();
2366 assert_eq!(content, "fresh binary");
2367 }
2368
2369 /// Mocked GitHub release payload covering both the dispatcher (`codewhale`)
2370 /// and the legacy TUI (`codewhale-tui`) binaries across our published
2371 /// platform/arch matrix, plus a checksum sibling that must never be picked
2372 /// as the primary binary.
2373 fn mocked_release() -> Release {
2374 let json = r#"{
2375 "tag_name": "v0.8.8",
2376 "assets": [
2377 { "name": "codewhale-linux-x64", "browser_download_url": "https://example.invalid/codewhale-linux-x64" },
2378 { "name": "codewhale-macos-x64", "browser_download_url": "https://example.invalid/codewhale-macos-x64" },
2379 { "name": "codewhale-macos-arm64", "browser_download_url": "https://example.invalid/codewhale-macos-arm64" },
2380 { "name": "codewhale-windows-x64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe" },
2381 { "name": "codewhale-windows-x64.exe.sha256", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe.sha256" },
2382 { "name": "codewhale-windows-arm64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-arm64.exe" },
2383 { "name": "codewhale-tui-linux-x64", "browser_download_url": "https://example.invalid/codewhale-tui-linux-x64" },
2384 { "name": "codewhale-tui-macos-x64", "browser_download_url": "https://example.invalid/codewhale-tui-macos-x64" },
2385 { "name": "codewhale-tui-macos-arm64", "browser_download_url": "https://example.invalid/codewhale-tui-macos-arm64" },
2386 { "name": "codewhale-tui-windows-x64.exe","browser_download_url": "https://example.invalid/codewhale-tui-windows-x64.exe" },
2387 { "name": "codewhale-tui-windows-arm64.exe","browser_download_url": "https://example.invalid/codewhale-tui-windows-arm64.exe" }
2388 ]
2389 }"#;
2390 serde_json::from_str(json).expect("mock release JSON")
2391 }
2392
2393 #[test]
2394 fn mocked_release_selects_dispatcher_asset_for_supported_platforms() {
2395 let release = mocked_release();
2396 let cases = [
2397 ("macos", "aarch64", "codewhale-macos-arm64"),
2398 ("macos", "x86_64", "codewhale-macos-x64"),
2399 ("linux", "x86_64", "codewhale-linux-x64"),
2400 ("windows", "x86_64", "codewhale-windows-x64.exe"),
2401 ("windows", "aarch64", "codewhale-windows-arm64.exe"),
2402 ];
2403
2404 for (os, arch, expected) in cases {
2405 let stem = release_asset_stem_for(Path::new("/usr/local/bin/codewhale"), os, arch);
2406 let asset = select_platform_asset(&release, &stem)
2407 .unwrap_or_else(|| panic!("no asset for {os}/{arch} (stem {stem})"));
2408 assert_eq!(asset.name, expected, "{os}/{arch}");
2409 }
2410 }
2411
2412 #[test]
2413 fn mocked_release_selects_tui_asset_when_tui_binary_invokes_update() {
2414 let release = mocked_release();
2415 let stem = release_asset_stem_for(
2416 Path::new("/usr/local/bin/codewhale-tui"),
2417 "macos",
2418 "aarch64",
2419 );
2420 let asset = select_platform_asset(&release, &stem).expect("TUI platform asset");
2421 assert_eq!(asset.name, "codewhale-tui-macos-arm64");
2422
2423 let windows_stem =
2424 release_asset_stem_for(Path::new("C:\\codewhale-tui.exe"), "windows", "aarch64");
2425 let windows_asset =
2426 select_platform_asset(&release, &windows_stem).expect("Windows ARM64 TUI asset");
2427 assert_eq!(windows_asset.name, "codewhale-tui-windows-arm64.exe");
2428 }
2429
2430 #[test]
2431 fn android_arm64_maps_to_android_release_assets() {
2432 // The generic format!("{prefix}-{os}-{arch}") path naturally produces
2433 // Android asset stems. Verify the full stem for both dispatcher and TUI
2434 // binaries so `codewhale update` on Termux requests Android assets, not
2435 // linux-arm64 (#4241).
2436 assert_eq!(
2437 release_asset_stem_for_prefix("codewhale", "android", "aarch64"),
2438 "codewhale-android-arm64"
2439 );
2440 assert_eq!(
2441 release_asset_stem_for_prefix("codewhale-tui", "android", "aarch64"),
2442 "codewhale-tui-android-arm64"
2443 );
2444 assert_eq!(
2445 release_asset_stem_for_prefix("codew", "android", "aarch64"),
2446 "codew-android-arm64"
2447 );
2448 }
2449
2450 #[test]
2451 fn ensure_supported_release_target_accepts_android() {
2452 // Android/Termux is a supported release target (#4241).
2453 assert!(ensure_supported_release_target("android", "aarch64").is_ok());
2454 }
2455
2456 #[test]
2457 fn android_release_assets_never_select_linux_arm64() {
2458 // Sanity: the stem formatter must never produce a linux-* stem for android.
2459 let stem = release_asset_stem_for_prefix("codewhale", "android", "aarch64");
2460 assert!(
2461 !stem.contains("linux"),
2462 "android stem must not contain linux: {stem}"
2463 );
2464 }
2465
2466 #[test]
2467 fn mirror_release_uses_base_url_and_platform_assets() {
2468 let release = release_from_mirror_base_url(
2469 "https://mirror.example/releases/v0.8.36/",
2470 "0.8.36",
2471 "linux",
2472 "x86_64",
2473 );
2474
2475 assert_eq!(release.tag_name, "v0.8.36");
2476 assert_eq!(release.assets[0].name, CHECKSUM_MANIFEST_ASSET);
2477 assert_eq!(
2478 release.assets[0].browser_download_url,
2479 "https://mirror.example/releases/v0.8.36/codewhale-artifacts-sha256.txt"
2480 );
2481
2482 let dispatcher =
2483 select_platform_asset(&release, "codewhale-linux-x64").expect("dispatcher asset");
2484 assert_eq!(
2485 dispatcher.browser_download_url,
2486 "https://mirror.example/releases/v0.8.36/codewhale-linux-x64"
2487 );
2488 let tui = select_platform_asset(&release, "codewhale-tui-linux-x64").expect("tui asset");
2489 assert_eq!(
2490 tui.browser_download_url,
2491 "https://mirror.example/releases/v0.8.36/codewhale-tui-linux-x64"
2492 );
2493 }
2494
2495 #[test]
2496 fn mirror_release_uses_windows_exe_asset_names() {
2497 let release = release_from_mirror_base_url(
2498 "https://mirror.example/releases/v0.8.36",
2499 "v0.8.36",
2500 "windows",
2501 "x86_64",
2502 );
2503
2504 assert_eq!(release.tag_name, "v0.8.36");
2505 assert!(
2506 select_platform_asset(&release, "codewhale-windows-x64")
2507 .is_some_and(|asset| asset.name == "codewhale-windows-x64.exe")
2508 );
2509 assert!(
2510 select_platform_asset(&release, "codewhale-tui-windows-x64")
2511 .is_some_and(|asset| asset.name == "codewhale-tui-windows-x64.exe")
2512 );
2513
2514 let arm_release = release_from_mirror_base_url(
2515 "https://mirror.example/releases/v0.9.1",
2516 "v0.9.1",
2517 "windows",
2518 "aarch64",
2519 );
2520 assert!(
2521 select_platform_asset(&arm_release, "codewhale-windows-arm64")
2522 .is_some_and(|asset| asset.name == "codewhale-windows-arm64.exe")
2523 );
2524 }
2525
2526 #[test]
2527 fn github_release_url_parser_extracts_tag() {
2528 let url = reqwest::Url::parse("https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.61")
2529 .unwrap();
2530
2531 assert_eq!(
2532 release_tag_from_github_release_url(&url).as_deref(),
2533 Some("v0.8.61")
2534 );
2535 }
2536
2537 #[test]
2538 fn github_release_download_fallback_uses_deterministic_asset_urls() {
2539 let release = release_from_github_download_tag("0.8.61", "macos", "aarch64");
2540
2541 assert_eq!(release.tag_name, "v0.8.61");
2542 assert_eq!(
2543 release.assets[0].browser_download_url,
2544 "https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-artifacts-sha256.txt"
2545 );
2546 let dispatcher =
2547 select_platform_asset(&release, "codewhale-macos-arm64").expect("dispatcher asset");
2548 assert_eq!(
2549 dispatcher.browser_download_url,
2550 "https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-macos-arm64"
2551 );
2552 let tui = select_platform_asset(&release, "codewhale-tui-macos-arm64").expect("tui asset");
2553 assert_eq!(
2554 tui.browser_download_url,
2555 "https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-tui-macos-arm64"
2556 );
2557 }
2558
2559 #[test]
2560 fn latest_stable_redirect_fallback_reads_tag_url() {
2561 let (url, request_rx, handle) = serve_http_once("200 OK", "text/html", b"<html></html>");
2562 let tag_url = url.replace("/release", "/Hmbown/CodeWhale/releases/tag/v9.9.9");
2563
2564 let tag = fetch_latest_stable_tag_from_redirect_url(&tag_url, None)
2565 .expect("tag should parse from final URL");
2566
2567 assert_eq!(tag, "v9.9.9");
2568 let request = request_rx.recv().expect("captured request");
2569 assert!(
2570 request.starts_with("GET /Hmbown/CodeWhale/releases/tag/v9.9.9 "),
2571 "got {request:?}"
2572 );
2573 handle.join().expect("test server thread");
2574 }
2575
2576 #[test]
2577 fn github_release_html_parser_skips_empty_first_marker() {
2578 let body = r#"
2579 <a href="/Hmbown/CodeWhale/releases/tag/?expanded=true">generic</a>
2580 <a href="/Hmbown/CodeWhale/releases/tag/v9.9.9">latest</a>
2581 "#;
2582
2583 assert_eq!(
2584 release_tag_from_github_release_html(body).as_deref(),
2585 Some("v9.9.9")
2586 );
2587 }
2588
2589 #[test]
2590 fn cnb_release_base_url_includes_tag_directory() {
2591 assert_eq!(
2592 codewhale_release::cnb_release_base_url("0.8.47"),
2593 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
2594 );
2595 assert_eq!(
2596 codewhale_release::cnb_release_base_url("v0.8.47"),
2597 "https://cnb.cool/Hmbown/CodeWhale/-/releases/v0.8.47"
2598 );
2599 }
2600
2601 #[test]
2602 fn stable_update_is_needed_only_when_latest_is_newer() {
2603 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
2604 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
2605 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
2606 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
2607 assert!(
2608 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
2609 );
2610 }
2611
2612 #[test]
2613 fn beta_update_allows_switching_from_same_stable_to_beta() {
2614 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
2615 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
2616 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
2617 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
2618 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
2619 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
2620 }
2621
2622 #[test]
2623 fn parse_release_version_accepts_tags_and_build_suffixes() {
2624 assert_eq!(
2625 codewhale_release::parse_release_version("v0.9.0-beta.1").unwrap(),
2626 semver::Version::parse("0.9.0-beta.1").unwrap()
2627 );
2628 assert_eq!(
2629 codewhale_release::parse_release_version("0.8.45 (abcdef123456)").unwrap(),
2630 semver::Version::parse("0.8.45").unwrap()
2631 );
2632 }
2633
2634 #[test]
2635 fn beta_release_detection_requires_beta_tag() {
2636 let rc_prerelease = Release {
2637 tag_name: "v0.9.0-rc.1".to_string(),
2638 prerelease: true,
2639 assets: vec![],
2640 };
2641 let beta_tag = Release {
2642 tag_name: "v0.9.0-beta.1".to_string(),
2643 prerelease: false,
2644 assets: vec![],
2645 };
2646 let stable = Release {
2647 tag_name: "v0.9.0".to_string(),
2648 prerelease: false,
2649 assets: vec![],
2650 };
2651
2652 assert!(!is_beta_tag(&rc_prerelease.tag_name));
2653 assert!(is_beta_tag(&beta_tag.tag_name));
2654 assert!(!is_beta_tag(&stable.tag_name));
2655 }
2656
2657 #[test]
2658 fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() {
2659 let hint = update_network_fallback_hint();
2660
2661 assert!(hint.contains(codewhale_release::CNB_REPO_URL), "{hint}");
2662 assert!(
2663 hint.contains(codewhale_release::RELEASE_BASE_URL_ENV),
2664 "{hint}"
2665 );
2666 assert!(
2667 hint.contains(codewhale_release::UPDATE_VERSION_ENV),
2668 "{hint}"
2669 );
2670 assert!(hint.contains("codewhale-cli"), "{hint}");
2671 assert!(hint.contains("codewhale-tui --locked"), "{hint}");
2672 }
2673
2674 fn serve_http_responses(
2675 responses: Vec<(&'static str, &'static str, &'static [u8])>,
2676 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
2677 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
2678 let addr = listener.local_addr().expect("test server addr");
2679 let (request_tx, request_rx) = mpsc::channel();
2680
2681 let handle = thread::spawn(move || {
2682 for (status, content_type, body) in responses {
2683 let (mut stream, _) = listener.accept().expect("accept test request");
2684 let mut buf = [0_u8; 4096];
2685 let n = stream.read(&mut buf).expect("read test request");
2686 request_tx
2687 .send(String::from_utf8_lossy(&buf[..n]).to_string())
2688 .expect("send captured request");
2689
2690 write!(
2691 stream,
2692 "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2693 body.len()
2694 )
2695 .expect("write test response headers");
2696 stream.write_all(body).expect("write test response body");
2697 }
2698 });
2699
2700 (format!("http://{addr}/release"), request_rx, handle)
2701 }
2702
2703 fn serve_http_once(
2704 status: &'static str,
2705 content_type: &'static str,
2706 body: &'static [u8],
2707 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
2708 serve_http_responses(vec![(status, content_type, body)])
2709 }
2710
2711 #[test]
2712 fn validate_and_build_proxy_accepts_supported_proxy_urls() {
2713 validate_and_build_proxy("http://localhost:7897").expect("http proxy");
2714 validate_and_build_proxy("https://proxy.example.com:8080").expect("https proxy");
2715 validate_and_build_proxy("socks5://127.0.0.1:1080").expect("socks proxy");
2716 }
2717
2718 #[test]
2719 fn validate_and_build_proxy_rejects_malformed_urls() {
2720 let err = validate_and_build_proxy("not a valid url").expect_err("malformed URL");
2721 assert!(err.to_string().contains("invalid proxy URL"));
2722 }
2723
2724 #[test]
2725 fn fetch_latest_release_from_url_reads_mocked_release_json() {
2726 let body = br#"{
2727 "tag_name": "v9.9.9",
2728 "assets": [
2729 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" },
2730 { "name": "codewhale-artifacts-sha256.txt", "browser_download_url": "http://example.invalid/codewhale-artifacts-sha256.txt" }
2731 ]
2732 }"#;
2733 let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body);
2734 let release = fetch_latest_release_from_url(&url, None).expect("release JSON should parse");
2735
2736 assert_eq!(release.tag_name, "v9.9.9");
2737 assert_eq!(release.assets.len(), 2);
2738
2739 let request = request_rx.recv().expect("captured request");
2740 let request_lower = request.to_ascii_lowercase();
2741 assert!(request.starts_with("GET /release "), "got {request:?}");
2742 assert!(
2743 request_lower.contains("accept: application/vnd.github+json"),
2744 "got {request:?}"
2745 );
2746 assert!(
2747 request_lower.contains("user-agent: codewhale-updater"),
2748 "got {request:?}"
2749 );
2750 handle.join().expect("test server thread");
2751 }
2752
2753 #[test]
2754 fn fetch_latest_release_from_url_retries_transient_gateway_error() {
2755 let body = br#"{
2756 "tag_name": "v9.9.9",
2757 "assets": [
2758 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" }
2759 ]
2760 }"#;
2761 let (url, request_rx, handle) = serve_http_responses(vec![
2762 ("504 Gateway Timeout", "text/plain", b"gateway timeout"),
2763 ("200 OK", "application/json", body),
2764 ]);
2765 let release = fetch_latest_release_from_url(&url, None)
2766 .expect("release JSON should parse after retry");
2767
2768 assert_eq!(release.tag_name, "v9.9.9");
2769 let first = request_rx.recv().expect("first request");
2770 let second = request_rx.recv().expect("second request");
2771 assert!(first.starts_with("GET /release "), "got {first:?}");
2772 assert!(second.starts_with("GET /release "), "got {second:?}");
2773 handle.join().expect("test server thread");
2774 }
2775
2776 #[test]
2777 fn fetch_latest_release_from_url_reports_http_errors() {
2778 let (url, _request_rx, handle) = serve_http_responses(vec![
2779 ("500 Internal Server Error", "text/plain", b"server broke"),
2780 ("500 Internal Server Error", "text/plain", b"server broke"),
2781 ("500 Internal Server Error", "text/plain", b"server broke"),
2782 ]);
2783 let err = fetch_latest_release_from_url(&url, None).expect_err("HTTP 500 should fail");
2784
2785 assert!(
2786 err.to_string().contains("HTTP 500"),
2787 "unexpected error: {err:#}"
2788 );
2789 handle.join().expect("test server thread");
2790 }
2791
2792 #[test]
2793 fn fetch_latest_beta_release_from_url_selects_first_beta_release() {
2794 let body = br#"[
2795 { "tag_name": "v0.9.0", "prerelease": false, "assets": [] },
2796 { "tag_name": "v0.9.0-rc.1", "prerelease": true, "assets": [] },
2797 { "tag_name": "v0.9.0-beta.2", "prerelease": true, "assets": [
2798 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" }
2799 ] },
2800 { "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] }
2801 ]"#;
2802 let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body);
2803 let release =
2804 fetch_latest_beta_release_from_url(&url, None).expect("beta release JSON should parse");
2805
2806 assert_eq!(release.tag_name, "v0.9.0-beta.2");
2807 assert!(release.prerelease);
2808
2809 let request = request_rx.recv().expect("captured request");
2810 let request_lower = request.to_ascii_lowercase();
2811 assert!(request.starts_with("GET /release "), "got {request:?}");
2812 assert!(
2813 request_lower.contains("accept: application/vnd.github+json"),
2814 "got {request:?}"
2815 );
2816 handle.join().expect("test server thread");
2817 }
2818
2819 #[test]
2820 fn fetch_latest_beta_release_from_url_reports_missing_beta() {
2821 let body = br#"[
2822 { "tag_name": "v0.9.0", "prerelease": false, "assets": [] }
2823 ]"#;
2824 let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body);
2825 let err =
2826 fetch_latest_beta_release_from_url(&url, None).expect_err("missing beta should fail");
2827
2828 assert!(
2829 err.to_string().contains("no beta release found"),
2830 "unexpected error: {err:#}"
2831 );
2832 handle.join().expect("test server thread");
2833 }
2834
2835 #[test]
2836 fn download_url_retries_transient_gateway_error() {
2837 let (url, request_rx, handle) = serve_http_responses(vec![
2838 ("503 Service Unavailable", "text/plain", b"try again"),
2839 ("200 OK", "application/octet-stream", b"\0binary bytes"),
2840 ]);
2841 let bytes = download_url(&url, None).expect("binary download should retry and succeed");
2842
2843 assert_eq!(bytes, b"\0binary bytes");
2844 let first = request_rx.recv().expect("first request");
2845 let second = request_rx.recv().expect("second request");
2846 assert!(first.starts_with("GET /release "), "got {first:?}");
2847 assert!(second.starts_with("GET /release "), "got {second:?}");
2848 handle.join().expect("test server thread");
2849 }
2850
2851 #[test]
2852 fn download_url_reads_binary_body_with_updater_user_agent() {
2853 let (url, request_rx, handle) =
2854 serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes");
2855 let bytes = download_url(&url, None).expect("binary download should succeed");
2856
2857 assert_eq!(bytes, b"\0binary bytes");
2858
2859 let request = request_rx.recv().expect("captured request");
2860 let request_lower = request.to_ascii_lowercase();
2861 assert!(request.starts_with("GET /release "), "got {request:?}");
2862 assert!(
2863 request_lower.contains("user-agent: codewhale-updater"),
2864 "got {request:?}"
2865 );
2866 handle.join().expect("test server thread");
2867 }
2868 }
2869
2869 lines RUST