返回 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::install::GITHUB_MIGRATION_HELP;
18 use codewhale_release::{
19 CHECKSUM_MANIFEST_ASSET, InstallMethod, ReleaseChannel, ReleaseQuery, UPDATE_USER_AGENT,
20 cnb_mirror_override_active, cnb_mirror_supports_target, cnb_release_base_url,
21 compare_release_versions, is_beta_tag, mirror_asset_url, resolve_release_query,
22 update_is_needed, update_network_fallback_hint,
23 };
24 use reqwest::Proxy;
25 use std::io::Write;
26 use std::sync::Arc;
27 use std::time::Duration;
28
29 const GITHUB_LATEST_RELEASE_PAGE_URL: &str = "https://github.com/Hmbown/CodeWhale/releases/latest";
30 const GITHUB_RELEASE_DOWNLOAD_BASE_URL: &str =
31 "https://github.com/Hmbown/CodeWhale/releases/download";
32 const UPDATE_HTTP_ATTEMPTS: usize = 3;
33 const UPDATE_HTTP_RETRY_DELAY_MS: u64 = 100;
34 /// Ceiling for one asset download. Generous, because release binaries are tens
35 /// of megabytes and some of the networks this exists for are slow.
36 const UPDATE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
37 /// Ceiling for one checksum-manifest probe. The manifest is a few hundred
38 /// bytes, so this is only a backstop against a source that accepts the
39 /// connection and then stalls. GitHub gets the first attempt; an unavailable
40 /// manifest falls back to the supported mirror without waiting for a binary.
41 const MANIFEST_PROBE_TIMEOUT: Duration = Duration::from_secs(10);
42 #[cfg(target_os = "android")]
43 const ANDROID_PROC_SELF_MAPS: &str = "/proc/self/maps";
44
45 /// Run the self-update workflow.
46 ///
47 /// OpenHarmony (HarmonyOS) won't compile this file, so no need to handle
48 pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Result<()> {
49 let executable_identity = update_executable_identity()?;
50 let current_exe = executable_identity.path.clone();
51 let install_method = InstallMethod::detect(&current_exe);
52 let protected_location = protected_update_path(&current_exe);
53 let legacy_binary = is_legacy_binary(&current_exe);
54 ensure_supported_release_target(std::env::consts::OS, std::env::consts::ARCH)?;
55
56 let plan = update_plan_for_exe(&current_exe);
57 let channel = ReleaseChannel::from_beta_flag(beta);
58 let current_version = env!("CARGO_PKG_VERSION");
59 let proxy = proxy_arg
60 .as_deref()
61 .map(validate_and_build_proxy)
62 .transpose()?;
63
64 println!("Checking for {} updates...", channel.label());
65 println!("Current binary: {}", current_exe.display());
66 println!("Current version: v{current_version}");
67 if legacy_binary {
68 println!();
69 println!("{}", legacy_binary_message(&current_exe));
70 }
71 if let Some(warning) = managed_install_warning(install_method) {
72 println!();
73 println!("{warning}");
74 if !check_only {
75 bail!("The package-managed executable was not changed.");
76 }
77 }
78 if protected_location {
79 println!(
80 "System/package directory: in-place self-update is disabled.\n\n{GITHUB_MIGRATION_HELP}"
81 );
82 if !check_only {
83 bail!("The system/package executable was not changed.");
84 }
85 }
86
87 if check_only {
88 let fetched = fetch_latest_release(channel, proxy.as_ref())
89 .with_context(update_network_fallback_hint)?;
90 let latest_tag = &fetched.release.tag_name;
91 println!("Latest {} release: {latest_tag}", channel.label());
92 if update_is_needed(channel, current_version, latest_tag)? {
93 if install_method.supports_self_update() && !protected_location {
94 println!(
95 "Update available. Run `{} update` to install {latest_tag}.",
96 current_exe.display()
97 );
98 } else if !install_method.supports_self_update() {
99 println!(
100 "Update available. Use the GitHub installation instructions above, or `{}` for this package-managed copy.",
101 install_method.update_command()
102 );
103 } else {
104 println!("Update available. Use the GitHub installation instructions above.");
105 }
106 println!(
107 "Release source: {}",
108 describe_release_source_for_check(&fetched, &plan.asset_stem, proxy.as_ref())
109 );
110 } else {
111 match compare_release_versions(current_version, latest_tag)? {
112 Ordering::Greater => {
113 println!("Current build is newer than the latest published release.");
114 }
115 Ordering::Less | Ordering::Equal => {
116 println!("Already up to date.");
117 }
118 }
119 }
120 return Ok(());
121 }
122
123 // Step 1: Fetch latest release metadata
124 let fetched =
125 fetch_latest_release(channel, proxy.as_ref()).with_context(update_network_fallback_hint)?;
126 let release = &fetched.release;
127 let latest_tag = &release.tag_name;
128 println!("Latest {} release: {latest_tag}", channel.label());
129
130 if fetched.source.is_pinned_mirror() && channel == ReleaseChannel::Beta {
131 println!(
132 "Using {}; --beta does not select GitHub beta releases in mirror mode.",
133 fetched.source.describe()
134 );
135 }
136 if !update_is_needed(channel, current_version, latest_tag)? {
137 if compare_release_versions(current_version, latest_tag)? == Ordering::Greater {
138 println!(
139 "Current build is newer than the latest published release; keeping v{current_version}. No downgrade or download performed."
140 );
141 } else {
142 println!("Already up to date; no download needed.");
143 }
144 return Ok(());
145 }
146
147 // Reject unrelated command paths before downloads or any sibling changes.
148 for target in &plan.target_paths {
149 validate_update_target(target, &executable_identity)?;
150 }
151
152 // Step 2: Prefer GitHub, then a supported mirror if its manifest is
153 // unavailable. Keep the manifest and binary locked to the same source.
154 let download = resolve_download_plan(&fetched, &plan.asset_stem, proxy.as_ref())?;
155 println!("Release source: {}", download.source.describe());
156
157 // Step 3: Download and verify the sole implementation binary once. The
158 // installed `codew` and pre-0.9.5 `codewhale-tui` command paths are
159 // compatibility names for these exact bytes, not separate release assets.
160 println!("Downloading {}...", download.binary_name);
161 let bytes = download_url(&download.binary_url, proxy.as_ref()).with_context(|| {
162 format!(
163 "failed to download {} from {}\n{}",
164 download.binary_name,
165 download.source.describe(),
166 update_network_fallback_hint()
167 )
168 })?;
169
170 verify_downloaded_asset(&download, &bytes)?;
171
172 preflight_downloaded_binary(&download.binary_name, &bytes)?;
173
174 println!(
175 "SHA256 checksum verified against {CHECKSUM_MANIFEST_ASSET} from {}.",
176 download.source.label()
177 );
178
179 // Step 4: Replace command paths only after the download and the running
180 // executable identity verify. The preflight happens before a colocated
181 // compatibility path can change, then the identity is checked just in time.
182 replace_verified_downloads(&plan.target_paths, &bytes, |target| {
183 validate_primary_update_identity(&executable_identity)?;
184 validate_update_target(target, &executable_identity)
185 })?;
186
187 println!(
188 "\n✅ Successfully updated to {latest_tag}!\n\
189 Release source: {source}\n\
190 Updated binaries:\n{targets}\n\
191 \n\
192 Restart the application to use the new version.",
193 source = download.source.describe(),
194 targets = plan
195 .target_paths
196 .iter()
197 .map(|path| format!(" - {} ({})", path.display(), download.binary_name))
198 .collect::<Vec<_>>()
199 .join("\n")
200 );
201
202 Ok(())
203 }
204
205 /// Fail closed when the downloaded bytes do not match the manifest that came
206 /// from the same source. A mismatch is never a reason to install anyway, and
207 /// never a reason to retry against the source that lost the probe: the two
208 /// build their own artifacts, so their checksums are not interchangeable.
209 fn verify_downloaded_asset(download: &DownloadPlan, bytes: &[u8]) -> Result<()> {
210 let expected = download
211 .checksums
212 .get(&download.binary_name)
213 .with_context(|| {
214 format!(
215 "{CHECKSUM_MANIFEST_ASSET} from {} is missing {}",
216 download.source.describe(),
217 download.binary_name
218 )
219 })?;
220 let actual = sha256_hex(bytes);
221 if !actual.eq_ignore_ascii_case(expected) {
222 bail!(
223 "SHA256 mismatch for {} from {}!\n expected: {expected}\n actual: {actual}",
224 download.binary_name,
225 download.source.describe()
226 );
227 }
228 Ok(())
229 }
230
231 /// Explain how to move to GitHub releases without overwriting managed files.
232 fn managed_install_warning(method: InstallMethod) -> Option<String> {
233 if method.supports_self_update() {
234 return None;
235 }
236 Some(format!(
237 "This executable is managed by {label}; in-place self-update is disabled.\n\n\
238 {GITHUB_MIGRATION_HELP}\n\n\
239 To retain this secondary {label} installation, run `{command}`.",
240 label = method.label(),
241 command = method.update_command()
242 ))
243 }
244
245 /// Resolve the executable that the updater is allowed to replace.
246 ///
247 /// Android's `std::env::current_exe()`, `AT_EXECFN`, and `/proc/self/exe` can
248 /// all identify Bionic's runtime linker rather than the launched program. On
249 /// Android, locate a marker compiled into this executable with `dladdr`, then
250 /// require the executable `/proc/self/maps` row containing that same address
251 /// to agree by canonical path, device, and inode.
252 #[derive(Debug, Clone)]
253 struct UpdateExecutableIdentity {
254 path: PathBuf,
255 file_hash: String,
256 #[cfg(target_os = "android")]
257 android_proof: AndroidExecutableProof,
258 }
259
260 #[cfg(not(target_os = "android"))]
261 fn update_executable_identity() -> Result<UpdateExecutableIdentity> {
262 let path = std::env::current_exe().context("failed to determine current executable path")?;
263 let file_hash = sha256_hex(&std::fs::read(&path).context("failed to identify updater binary")?);
264 Ok(UpdateExecutableIdentity { path, file_hash })
265 }
266
267 #[cfg(target_os = "android")]
268 fn update_executable_identity() -> Result<UpdateExecutableIdentity> {
269 let android_proof = android_loaded_executable_proof()?;
270 Ok(UpdateExecutableIdentity {
271 file_hash: sha256_hex(
272 &std::fs::read(&android_proof.path).context("failed to identify updater binary")?,
273 ),
274 path: android_proof.path.clone(),
275 android_proof,
276 })
277 }
278
279 #[cfg(target_os = "android")]
280 #[inline(never)]
281 extern "C" fn android_update_image_marker() -> usize {
282 android_update_image_marker as *const () as usize
283 }
284
285 #[cfg(target_os = "android")]
286 fn android_loaded_executable_proof() -> Result<AndroidExecutableProof> {
287 let marker = android_update_image_marker as *const () as usize as u64;
288 let dladdr_path = android_dladdr_path(android_update_image_marker as *const libc::c_void)?;
289 let maps = std::fs::read_to_string(ANDROID_PROC_SELF_MAPS)
290 .context("failed to read Android executable mappings from /proc/self/maps")?;
291 android_loaded_executable_proof_report(&maps, marker, &dladdr_path)
292 }
293
294 #[cfg(target_os = "android")]
295 fn android_dladdr_path(marker: *const libc::c_void) -> Result<PathBuf> {
296 use std::os::unix::ffi::OsStrExt;
297
298 let mut info = std::mem::MaybeUninit::<libc::Dl_info>::zeroed();
299 // SAFETY: `marker` points to a function in this loaded image and `info`
300 // points to writable storage for the duration of the call.
301 let found = unsafe { libc::dladdr(marker, info.as_mut_ptr()) };
302 if found == 0 {
303 bail!("Android dladdr could not locate the updater's loaded image");
304 }
305 // SAFETY: A non-zero dladdr result initializes `info`.
306 let info = unsafe { info.assume_init() };
307 if info.dli_fname.is_null() {
308 bail!("Android dladdr returned an empty loaded-image path");
309 }
310 // SAFETY: `dli_fname` is a NUL-terminated string owned by the dynamic
311 // loader and remains valid while this image is loaded.
312 let bytes = unsafe { CStr::from_ptr(info.dli_fname) }.to_bytes();
313 if bytes.is_empty() {
314 bail!("Android dladdr returned an empty loaded-image path");
315 }
316 Ok(PathBuf::from(OsStr::from_bytes(bytes)))
317 }
318
319 #[cfg(any(target_os = "android", all(test, unix)))]
320 #[derive(Debug, Clone, PartialEq, Eq)]
321 struct AndroidImageMapping {
322 start: u64,
323 end: u64,
324 device_major: u32,
325 device_minor: u32,
326 inode: u64,
327 path: PathBuf,
328 }
329
330 #[cfg(any(target_os = "android", all(test, unix)))]
331 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
332 enum AndroidExecutableProofKind {
333 DladdrAndProcMaps,
334 }
335
336 #[cfg(any(target_os = "android", all(test, unix)))]
337 #[derive(Debug, Clone, PartialEq, Eq)]
338 struct AndroidExecutableProof {
339 path: PathBuf,
340 device_major: u32,
341 device_minor: u32,
342 inode: u64,
343 proof_kind: AndroidExecutableProofKind,
344 }
345
346 #[cfg(any(target_os = "android", all(test, unix)))]
347 fn parse_android_image_mapping(maps: &str, marker: u64) -> Result<AndroidImageMapping> {
348 let mut matching = None;
349 for (line_index, line) in maps.lines().enumerate() {
350 if line.trim().is_empty() {
351 continue;
352 }
353 let mut fields = line.split_whitespace();
354 let range = fields
355 .next()
356 .with_context(|| format!("malformed /proc/self/maps line {}", line_index + 1))?;
357 let (start, end) = range
358 .split_once('-')
359 .with_context(|| format!("malformed mapping range `{range}`"))?;
360 let start = u64::from_str_radix(start, 16)
361 .with_context(|| format!("invalid mapping start `{start}`"))?;
362 let end =
363 u64::from_str_radix(end, 16).with_context(|| format!("invalid mapping end `{end}`"))?;
364 if !(start <= marker && marker < end) {
365 continue;
366 }
367
368 let permissions = fields
369 .next()
370 .context("loaded-image mapping is missing permissions")?;
371 let _offset = fields
372 .next()
373 .context("loaded-image mapping is missing its file offset")?;
374 let device = fields
375 .next()
376 .context("loaded-image mapping is missing its device")?;
377 let inode = fields
378 .next()
379 .context("loaded-image mapping is missing its inode")?
380 .parse::<u64>()
381 .context("loaded-image mapping has an invalid inode")?;
382 let path = fields.collect::<Vec<_>>().join(" ");
383
384 if permissions.as_bytes().get(2) != Some(&b'x') {
385 bail!("loaded-image mapping for updater marker is not executable");
386 }
387 if inode == 0 {
388 bail!("loaded-image mapping for updater marker has no file inode");
389 }
390 let (device_major, device_minor) = device
391 .split_once(':')
392 .context("loaded-image mapping has an invalid device")?;
393 let device_major = u32::from_str_radix(device_major, 16)
394 .context("loaded-image mapping has an invalid device major number")?;
395 let device_minor = u32::from_str_radix(device_minor, 16)
396 .context("loaded-image mapping has an invalid device minor number")?;
397 if path.is_empty() {
398 bail!("loaded-image mapping for updater marker has no pathname");
399 }
400
401 let mapping = AndroidImageMapping {
402 start,
403 end,
404 device_major,
405 device_minor,
406 inode,
407 path: PathBuf::from(path),
408 };
409 if matching.replace(mapping).is_some() {
410 bail!("multiple /proc/self/maps rows contain the updater marker");
411 }
412 }
413
414 matching.ok_or_else(|| anyhow!("no /proc/self/maps row contains the updater marker"))
415 }
416
417 #[cfg(all(test, unix))]
418 fn resolve_android_loaded_executable_report(
419 maps: &str,
420 marker: u64,
421 dladdr_path: &Path,
422 ) -> Result<PathBuf> {
423 Ok(android_loaded_executable_proof_report(maps, marker, dladdr_path)?.path)
424 }
425
426 #[cfg(any(target_os = "android", all(test, unix)))]
427 fn android_loaded_executable_proof_report(
428 maps: &str,
429 marker: u64,
430 dladdr_path: &Path,
431 ) -> Result<AndroidExecutableProof> {
432 let mapping = parse_android_image_mapping(maps, marker)?;
433 validate_android_reported_path("dladdr", dladdr_path)?;
434 validate_android_reported_path("/proc/self/maps", &mapping.path)?;
435
436 let resolved_dladdr = dladdr_path.canonicalize().with_context(|| {
437 format!(
438 "failed to canonicalize Android dladdr path {}",
439 dladdr_path.display()
440 )
441 })?;
442 let resolved_mapping = mapping.path.canonicalize().with_context(|| {
443 format!(
444 "failed to canonicalize Android loaded-image mapping {}",
445 mapping.path.display()
446 )
447 })?;
448 if resolved_dladdr != resolved_mapping {
449 bail!(
450 "Android loaded-image authorities disagree: dladdr resolved to {}, but /proc/self/maps resolved to {}",
451 resolved_dladdr.display(),
452 resolved_mapping.display()
453 );
454 }
455 if is_android_linker_name(&resolved_mapping) {
456 bail!(
457 "Android loaded-image authorities resolved to runtime linker {}; refusing to use the linker as an update target",
458 resolved_mapping.display()
459 );
460 }
461 if !is_executable_file(&resolved_mapping) {
462 bail!(
463 "Android loaded image `{}` is not an executable regular file; refusing to select an update target",
464 resolved_mapping.display()
465 );
466 }
467
468 validate_android_mapping_identity(&mapping, &resolved_mapping)?;
469 Ok(AndroidExecutableProof {
470 path: resolved_mapping,
471 device_major: mapping.device_major,
472 device_minor: mapping.device_minor,
473 inode: mapping.inode,
474 proof_kind: AndroidExecutableProofKind::DladdrAndProcMaps,
475 })
476 }
477
478 #[cfg(any(target_os = "android", all(test, unix)))]
479 fn validate_android_reported_path(authority: &str, path: &Path) -> Result<()> {
480 if !path.is_absolute() {
481 bail!(
482 "Android {authority} reported non-absolute loaded-image path `{}`",
483 path.display()
484 );
485 }
486 if path.to_string_lossy().ends_with(" (deleted)") {
487 bail!(
488 "Android {authority} reported deleted loaded image `{}`",
489 path.display()
490 );
491 }
492 if is_android_linker_name(path) {
493 bail!(
494 "Android {authority} identifies runtime linker `{}`; refusing to use the linker as an update target",
495 path.display()
496 );
497 }
498 Ok(())
499 }
500
501 #[cfg(any(target_os = "android", all(test, unix)))]
502 fn validate_android_mapping_identity(
503 mapping: &AndroidImageMapping,
504 candidate: &Path,
505 ) -> Result<()> {
506 use std::os::unix::fs::MetadataExt;
507
508 let candidate_metadata = std::fs::metadata(candidate).with_context(|| {
509 format!(
510 "failed to stat Android update target {}",
511 candidate.display()
512 )
513 })?;
514 let (candidate_major, candidate_minor) = android_device_parts(candidate_metadata.dev());
515 let identity_matches = mapping.device_major == candidate_major
516 && mapping.device_minor == candidate_minor
517 && mapping.inode == candidate_metadata.ino();
518 if !identity_matches {
519 bail!(
520 "Android loaded-image identity changed: /proc/self/maps has device/inode {:x}:{:x}:{}, but update target {} is {:x}:{:x}:{}; refusing to replace it",
521 mapping.device_major,
522 mapping.device_minor,
523 mapping.inode,
524 candidate.display(),
525 candidate_major,
526 candidate_minor,
527 candidate_metadata.ino()
528 );
529 }
530 Ok(())
531 }
532
533 #[cfg(any(target_os = "android", all(test, unix)))]
534 fn android_device_parts(device: u64) -> (u32, u32) {
535 // Linux/Bionic's dev_t encoding, matching makedev(3), major(3), and
536 // minor(3). `/proc/self/maps` renders these components in hexadecimal.
537 let major = ((device >> 8) & 0xfff) as u32;
538 let minor = ((device & 0xff) | ((device >> 12) & 0xfff00)) as u32;
539 (major, minor)
540 }
541
542 fn validate_primary_update_identity(identity: &UpdateExecutableIdentity) -> Result<()> {
543 #[cfg(target_os = "android")]
544 {
545 let fresh = android_loaded_executable_proof()?;
546 if fresh != identity.android_proof {
547 bail!(
548 "Android loaded-image proof changed from {:?} to {:?}; refusing to replace the update target",
549 identity.android_proof,
550 fresh
551 );
552 }
553 }
554 let bytes = std::fs::read(&identity.path).context("failed to recheck updater binary")?;
555 if sha256_hex(&bytes) != identity.file_hash {
556 bail!(
557 "The running executable path changed during the update; no further files were replaced. Run the intended executable again by its full path."
558 );
559 }
560 Ok(())
561 }
562
563 /// Only the running binary and copies of those exact bytes are ours to update.
564 /// Command names alone do not establish ownership of an existing sibling.
565 fn validate_update_target(target: &Path, identity: &UpdateExecutableIdentity) -> Result<()> {
566 if !InstallMethod::from_path(target).supports_self_update() || protected_update_path(target) {
567 bail!(
568 "Refusing to replace managed/system path {}.\n\n{GITHUB_MIGRATION_HELP}",
569 target.display()
570 );
571 }
572 let metadata = match std::fs::symlink_metadata(target) {
573 Ok(metadata) => metadata,
574 Err(error) if error.kind() == std::io::ErrorKind::NotFound && target != identity.path => {
575 return Ok(());
576 }
577 Err(error) => {
578 return Err(error)
579 .with_context(|| format!("failed to inspect update target {}", target.display()));
580 }
581 };
582 if !metadata.is_file() || metadata.is_symlink() {
583 bail!(
584 "Refusing to replace {}: the update target is not a regular file.\n\n{GITHUB_MIGRATION_HELP}",
585 target.display()
586 );
587 }
588 let bytes = std::fs::read(target)
589 .with_context(|| format!("failed to identify update target {}", target.display()))?;
590 if sha256_hex(&bytes) != identity.file_hash {
591 bail!(
592 "Refusing to replace {}: its bytes differ from the running executable. This may be another installation or an unrelated command. No command is removed automatically.\n\n{GITHUB_MIGRATION_HELP}",
593 target.display()
594 );
595 }
596 Ok(())
597 }
598
599 fn protected_update_path(path: &Path) -> bool {
600 [
601 "/usr/bin",
602 "/usr/sbin",
603 "/bin",
604 "/sbin",
605 "/nix/store",
606 "/gnu/store",
607 ]
608 .iter()
609 .any(|prefix| path.starts_with(prefix))
610 || path.components().any(|component| {
611 component.as_os_str().to_str().is_some_and(|name| {
612 name.eq_ignore_ascii_case("Windows")
613 || name.eq_ignore_ascii_case("WindowsApps")
614 || name.eq_ignore_ascii_case("scoop")
615 || name.eq_ignore_ascii_case("chocolatey")
616 })
617 })
618 }
619
620 fn replace_verified_downloads<F>(
621 target_paths: &[PathBuf],
622 verified_bytes: &[u8],
623 validate_target: F,
624 ) -> Result<()>
625 where
626 F: Fn(&Path) -> Result<()>,
627 {
628 // Fail before mutating a sibling if the primary pathname no longer names
629 // the process image that initiated this update.
630 for path in target_paths {
631 validate_target(path)?;
632 }
633 for path in target_paths.iter().rev() {
634 replace_binary_with_validation(path, verified_bytes, || {
635 // Re-check after each temp file is fully staged and immediately
636 // before every destructive rename. The running command is first
637 // in the plan and therefore replaced last, after its colocated
638 // compatibility names have received the same verified bytes.
639 validate_target(path)
640 })?;
641 }
642 Ok(())
643 }
644
645 #[cfg(any(target_os = "android", all(test, unix)))]
646 fn is_android_linker_name(path: &Path) -> bool {
647 path.file_name()
648 .and_then(OsStr::to_str)
649 .is_some_and(|name| {
650 matches!(
651 name,
652 "linker"
653 | "linker64"
654 | "linker_asan"
655 | "linker_asan64"
656 | "linker_hwasan"
657 | "linker_hwasan64"
658 )
659 })
660 }
661
662 #[cfg(any(target_os = "android", all(test, unix)))]
663 fn is_executable_file(path: &Path) -> bool {
664 let Ok(metadata) = std::fs::metadata(path) else {
665 return false;
666 };
667 if !metadata.is_file() {
668 return false;
669 }
670
671 #[cfg(unix)]
672 {
673 use std::os::unix::fs::PermissionsExt;
674 metadata.permissions().mode() & 0o111 != 0
675 }
676
677 #[cfg(not(unix))]
678 {
679 true
680 }
681 }
682
683 #[derive(Debug, Clone, PartialEq, Eq)]
684 struct FetchedRelease {
685 release: Release,
686 source: UpdateReleaseSource,
687 }
688
689 /// Where a release's assets come from.
690 ///
691 /// This names the *asset* origin, which is not always the origin of the release
692 /// metadata: without an override the tag is resolved from GitHub, and only then
693 /// is the asset source chosen between GitHub and the first-party CNB mirror.
694 #[derive(Debug, Clone, PartialEq, Eq)]
695 enum UpdateReleaseSource {
696 /// Canonical GitHub Releases.
697 GitHub,
698 /// The first-party CNB mirror release for this exact tag (Linux x64 only).
699 Cnb { base_url: String },
700 /// An operator-supplied asset directory (`CODEWHALE_RELEASE_BASE_URL`).
701 Mirror { base_url: String },
702 }
703
704 impl UpdateReleaseSource {
705 /// Short, stable name for status output.
706 fn label(&self) -> &'static str {
707 match self {
708 Self::GitHub => "GitHub Releases",
709 Self::Cnb { .. } => "CNB mirror",
710 Self::Mirror { .. } => "release mirror",
711 }
712 }
713
714 /// The asset directory this source serves from, when it has one.
715 fn base_url(&self) -> Option<&str> {
716 match self {
717 Self::GitHub => None,
718 Self::Cnb { base_url } | Self::Mirror { base_url } => Some(base_url),
719 }
720 }
721
722 /// Label plus asset directory — what status lines and the final receipt
723 /// print, so "which source did this binary come from?" is answerable
724 /// without rerunning the updater.
725 fn describe(&self) -> String {
726 match self.base_url() {
727 Some(base_url) => format!("{} ({base_url})", self.label()),
728 None => self.label().to_string(),
729 }
730 }
731
732 /// True when an environment override, not a probe, chose this source. Such
733 /// a source also carries the pinned version. The same no-downgrade and
734 /// already-current checks apply before downloading from any source.
735 fn is_pinned_mirror(&self) -> bool {
736 !matches!(self, Self::GitHub)
737 }
738 }
739
740 /// One source that could serve this release, and the two URLs that must come
741 /// from it together: the checksum manifest, and the binary that manifest
742 /// covers.
743 #[derive(Debug, Clone, PartialEq, Eq)]
744 struct ReleaseSourceCandidate {
745 source: UpdateReleaseSource,
746 manifest_url: String,
747 binary_name: String,
748 binary_url: String,
749 }
750
751 /// A source locked in for this update, with its manifest already fetched,
752 /// parsed, and confirmed to cover the binary we are about to download.
753 #[derive(Debug, Clone, PartialEq, Eq)]
754 struct DownloadPlan {
755 source: UpdateReleaseSource,
756 binary_name: String,
757 binary_url: String,
758 /// Parsed checksums from this same source, already confirmed to cover
759 /// `binary_name`. A plan cannot exist without this proof.
760 checksums: HashMap<String, String>,
761 }
762
763 /// Fetches one candidate's checksum manifest. Injected so the selection logic
764 /// can be tested without a network.
765 type ManifestFetcher = dyn Fn(&ReleaseSourceCandidate) -> Result<Vec<u8>> + Send + Sync;
766
767 /// Build the candidate list for proactive source selection, or `None` when this
768 /// update keeps a single canonical source.
769 ///
770 /// Selection applies only when the release metadata came from GitHub (an
771 /// explicit override already named the source) and the target is one the CNB
772 /// mirror actually publishes. Every other target is left exactly as it was.
773 fn proactive_source_candidates(
774 fetched: &FetchedRelease,
775 asset_stem: &str,
776 os: &str,
777 rust_arch: &str,
778 ) -> Option<Vec<ReleaseSourceCandidate>> {
779 if fetched.source != UpdateReleaseSource::GitHub || !cnb_mirror_supports_target(os, rust_arch) {
780 return None;
781 }
782 let mut candidates = Vec::new();
783 if let Some(github) = github_source_candidate(&fetched.release, asset_stem) {
784 candidates.push(github);
785 }
786 candidates.push(cnb_source_candidate(
787 &fetched.release.tag_name,
788 os,
789 rust_arch,
790 ));
791 Some(candidates)
792 }
793
794 /// The canonical GitHub candidate for a release the API already described.
795 ///
796 /// Asset URLs come from the release payload when it advertises them. A release
797 /// that lists the platform binary but not the manifest still gets a candidate:
798 /// GitHub serves release assets from a stable per-tag path, so the manifest is
799 /// addressable even when the payload omits it.
800 fn github_source_candidate(release: &Release, asset_stem: &str) -> Option<ReleaseSourceCandidate> {
801 let asset = select_platform_asset(release, asset_stem)?;
802 let manifest_url = select_checksum_manifest_asset(release)
803 .map(|manifest| manifest.browser_download_url.clone())
804 .unwrap_or_else(|| {
805 let tag_name = format!("v{}", release.tag_name.trim_start_matches('v'));
806 mirror_asset_url(
807 &format!("{GITHUB_RELEASE_DOWNLOAD_BASE_URL}/{tag_name}"),
808 CHECKSUM_MANIFEST_ASSET,
809 )
810 });
811 Some(ReleaseSourceCandidate {
812 source: UpdateReleaseSource::GitHub,
813 manifest_url,
814 binary_name: asset.name.clone(),
815 binary_url: asset.browser_download_url.clone(),
816 })
817 }
818
819 /// The first-party CNB candidate for this exact tag.
820 ///
821 /// CNB builds its own artifacts from the tagged source, so its manifest only
822 /// describes its own binaries — which is precisely why the manifest and the
823 /// binary have to be taken from the same source.
824 fn cnb_source_candidate(tag_name: &str, os: &str, rust_arch: &str) -> ReleaseSourceCandidate {
825 let base_url = cnb_release_base_url(tag_name);
826 let binary_name = release_asset_name_for_prefix("codewhale", os, rust_arch);
827 ReleaseSourceCandidate {
828 manifest_url: mirror_asset_url(&base_url, CHECKSUM_MANIFEST_ASSET),
829 binary_url: mirror_asset_url(&base_url, &binary_name),
830 binary_name,
831 source: UpdateReleaseSource::Cnb { base_url },
832 }
833 }
834
835 /// Decide where this update's bytes come from, and prove the choice before
836 /// committing to it.
837 fn resolve_download_plan(
838 fetched: &FetchedRelease,
839 asset_stem: &str,
840 proxy: Option<&Proxy>,
841 ) -> Result<DownloadPlan> {
842 match proactive_source_candidates(
843 fetched,
844 asset_stem,
845 std::env::consts::OS,
846 std::env::consts::ARCH,
847 ) {
848 Some(candidates) => {
849 println!(
850 "Probing {CHECKSUM_MANIFEST_ASSET} for {} from {}...",
851 fetched.release.tag_name,
852 candidate_labels(&candidates)
853 );
854 select_release_source(candidates, manifest_probe_fetcher(proxy))
855 .with_context(update_network_fallback_hint)
856 }
857 None => single_source_download_plan(fetched, asset_stem, proxy),
858 }
859 }
860
861 fn candidate_labels(candidates: &[ReleaseSourceCandidate]) -> String {
862 candidates
863 .iter()
864 .map(|candidate| candidate.source.label())
865 .collect::<Vec<_>>()
866 .join(" and ")
867 }
868
869 /// Name the source `--check` would download from, without downloading anything
870 /// bigger than a manifest — and without contacting anything at all when an
871 /// override already fixed the answer.
872 fn describe_release_source_for_check(
873 fetched: &FetchedRelease,
874 asset_stem: &str,
875 proxy: Option<&Proxy>,
876 ) -> String {
877 let Some(candidates) = proactive_source_candidates(
878 fetched,
879 asset_stem,
880 std::env::consts::OS,
881 std::env::consts::ARCH,
882 ) else {
883 return fetched.source.describe();
884 };
885 match select_release_source(candidates, manifest_probe_fetcher(proxy)) {
886 Ok(plan) => plan.source.describe(),
887 // A failed probe is a real answer for `--check` to report, not a reason
888 // to fail a command whose whole job is to describe the release.
889 Err(error) => format!("unresolved — {error:#}"),
890 }
891 }
892
893 /// Resolve an explicit source or a platform without a supported fallback.
894 ///
895 /// This path is still fail-closed: every platform and every explicit mirror
896 /// must publish a valid manifest from the same source that covers the selected
897 /// binary. The binary is not downloaded until that proof exists.
898 fn single_source_download_plan(
899 fetched: &FetchedRelease,
900 asset_stem: &str,
901 proxy: Option<&Proxy>,
902 ) -> Result<DownloadPlan> {
903 let release = &fetched.release;
904 let asset = select_platform_asset(release, asset_stem).with_context(|| {
905 format!(
906 "no asset found for platform {asset_stem} in release {}. \
907 Available assets: {}",
908 release.tag_name,
909 release
910 .assets
911 .iter()
912 .map(|asset| asset.name.as_str())
913 .collect::<Vec<_>>()
914 .join(", ")
915 )
916 })?;
917
918 let checksum_asset = select_checksum_manifest_asset(release).with_context(|| {
919 format!(
920 "release {} from {} does not publish required {CHECKSUM_MANIFEST_ASSET}; refusing to download {} without checksum verification",
921 release.tag_name,
922 fetched.source.describe(),
923 asset.name
924 )
925 })?;
926 println!("Downloading {}...", checksum_asset.name);
927 let checksum_bytes = download_url_with_timeout(
928 &checksum_asset.browser_download_url,
929 proxy,
930 MANIFEST_PROBE_TIMEOUT,
931 )
932 .with_context(|| {
933 format!(
934 "failed to download {} from {}\n{}",
935 checksum_asset.name,
936 fetched.source.describe(),
937 update_network_fallback_hint()
938 )
939 })?;
940 let checksum_text = std::str::from_utf8(&checksum_bytes)
941 .with_context(|| format!("{} is not valid UTF-8", checksum_asset.name))?;
942 let checksums = parse_checksum_manifest(checksum_text).with_context(|| {
943 format!(
944 "failed to parse {} from {}",
945 checksum_asset.name,
946 fetched.source.describe()
947 )
948 })?;
949 if !checksums.contains_key(&asset.name) {
950 bail!(
951 "{} from {} does not list {}; refusing to download an unverified update",
952 checksum_asset.name,
953 fetched.source.describe(),
954 asset.name
955 );
956 }
957
958 Ok(DownloadPlan {
959 source: fetched.source.clone(),
960 binary_name: asset.name.clone(),
961 binary_url: asset.browser_download_url.clone(),
962 checksums,
963 })
964 }
965
966 fn manifest_probe_fetcher(proxy: Option<&Proxy>) -> Arc<ManifestFetcher> {
967 let proxy = proxy.cloned();
968 Arc::new(move |candidate: &ReleaseSourceCandidate| {
969 download_url_with_timeout(
970 &candidate.manifest_url,
971 proxy.as_ref(),
972 MANIFEST_PROBE_TIMEOUT,
973 )
974 })
975 }
976
977 /// Try the official GitHub manifest first. Only an unavailable or unusable
978 /// manifest admits the next configured source; a faster mirror never races
979 /// GitHub. Each network probe has its own bounded timeout and retry policy.
980 fn select_release_source(
981 candidates: Vec<ReleaseSourceCandidate>,
982 fetch_manifest: Arc<ManifestFetcher>,
983 ) -> Result<DownloadPlan> {
984 if candidates.is_empty() {
985 bail!("no release source publishes an asset for this platform");
986 }
987
988 let mut failures = Vec::new();
989 for candidate in candidates {
990 match probe_release_source(&candidate, &*fetch_manifest) {
991 Ok(checksums) => {
992 return Ok(DownloadPlan {
993 source: candidate.source,
994 binary_name: candidate.binary_name,
995 binary_url: candidate.binary_url,
996 checksums,
997 });
998 }
999 Err(error) => failures.push(format!(" - {}: {error:#}", candidate.source.describe())),
1000 }
1001 }
1002
1003 bail!(
1004 "no release source published a usable {CHECKSUM_MANIFEST_ASSET} for this platform:\n{}",
1005 failures.join("\n")
1006 )
1007 }
1008
1009 fn probe_release_source(
1010 candidate: &ReleaseSourceCandidate,
1011 fetch_manifest: &ManifestFetcher,
1012 ) -> Result<HashMap<String, String>> {
1013 let bytes = fetch_manifest(candidate)
1014 .with_context(|| format!("failed to fetch {}", candidate.manifest_url))?;
1015 let text = std::str::from_utf8(&bytes)
1016 .with_context(|| format!("{} is not valid UTF-8", candidate.manifest_url))?;
1017 let checksums = parse_checksum_manifest(text)
1018 .with_context(|| format!("failed to parse {}", candidate.manifest_url))?;
1019 if !checksums.contains_key(&candidate.binary_name) {
1020 bail!(
1021 "{} does not list {}",
1022 candidate.manifest_url,
1023 candidate.binary_name
1024 );
1025 }
1026 Ok(checksums)
1027 }
1028
1029 fn ensure_supported_release_target(os: &str, arch: &str) -> Result<()> {
1030 if os == "linux" && arch == "riscv64" {
1031 bail!(
1032 "Linux riscv64 release assets are temporarily unavailable because \
1033 rquickjs-sys 0.12.0 does not ship riscv64gc-unknown-linux-gnu bindings. \
1034 See docs/INSTALL.md for the current platform matrix."
1035 );
1036 }
1037 Ok(())
1038 }
1039
1040 pub(crate) fn release_arch_for_rust_arch(arch: &str) -> &str {
1041 match arch {
1042 "aarch64" => "arm64",
1043 "x86_64" => "x64",
1044 other => other,
1045 }
1046 }
1047
1048 /// Returns true when the binary name belongs to the pre-rebrand `deepseek-tui` era.
1049 pub(crate) fn is_legacy_binary(current_exe: &Path) -> bool {
1050 let exe_name = current_exe
1051 .file_name()
1052 .and_then(|name| name.to_str())
1053 .unwrap_or("")
1054 .to_ascii_lowercase();
1055 exe_name.starts_with("deepseek")
1056 }
1057
1058 fn legacy_binary_message(current_exe: &Path) -> String {
1059 format!(
1060 "\
1061 this binary ({exe}) is using the legacy deepseek/deepseek-tui command name.
1062
1063 The package has been renamed to `codewhale`. A supported direct update can
1064 install the canonical `codewhale` command beside this legacy command when a
1065 newer verified release is available and the destination paths are safe to use.
1066 DeepSeek provider support is unchanged.
1067
1068 {GITHUB_MIGRATION_HELP}
1069
1070 Existing npm, Cargo, Homebrew, or system-managed commands are left to their
1071 package manager. See docs/INSTALL.md for secondary package routes.
1072
1073 Once `codewhale` is on your PATH, run `codewhale update` for future updates.",
1074 exe = current_exe.display(),
1075 )
1076 }
1077
1078 fn command_name_for_exe(current_exe: &Path) -> String {
1079 let exe_name = current_exe
1080 .file_name()
1081 .and_then(|name| name.to_str())
1082 .unwrap_or("codewhale")
1083 .to_ascii_lowercase();
1084 exe_name
1085 .strip_suffix(".exe")
1086 .unwrap_or(&exe_name)
1087 .to_string()
1088 }
1089
1090 fn command_path_beside(current_exe: &Path, command: &str) -> PathBuf {
1091 current_exe.with_file_name(format!("{command}{}", std::env::consts::EXE_SUFFIX))
1092 }
1093
1094 fn installed_command_path(current_exe: &Path, command: &str) -> PathBuf {
1095 if command_name_for_exe(current_exe) == command {
1096 current_exe.to_path_buf()
1097 } else {
1098 command_path_beside(current_exe, command)
1099 }
1100 }
1101
1102 fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
1103 if !paths.iter().any(|existing| existing == &path) {
1104 paths.push(path);
1105 }
1106 }
1107
1108 fn push_update_path(paths: &mut Vec<PathBuf>, path: PathBuf, current_exe: &Path) {
1109 // Keep a same-target symlink as a symlink. Replacing its target refreshes
1110 // the alias too. Foreign/broken links stay in the plan and fail validation.
1111 let same_target_link = std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_symlink())
1112 && std::fs::canonicalize(&path).is_ok_and(|resolved| resolved == current_exe);
1113 if !same_target_link {
1114 push_unique_path(paths, path);
1115 }
1116 }
1117
1118 fn legacy_tui_command_exists_beside(current_exe: &Path) -> bool {
1119 command_name_for_exe(current_exe) == "deepseek-tui"
1120 || command_path_beside(current_exe, "deepseek-tui").exists()
1121 }
1122
1123 #[derive(Debug, Clone, PartialEq, Eq)]
1124 struct UpdatePlan {
1125 target_paths: Vec<PathBuf>,
1126 asset_stem: String,
1127 }
1128
1129 fn update_plan_for_exe(current_exe: &Path) -> UpdatePlan {
1130 let mut target_paths = Vec::new();
1131
1132 // Keep the process image first so reverse-order replacement updates the
1133 // command currently running the updater last. Pre-rebrand command names
1134 // retain their historical migration behavior: install canonical commands
1135 // beside them instead of overwriting the legacy path.
1136 if !is_legacy_binary(current_exe) {
1137 push_unique_path(&mut target_paths, current_exe.to_path_buf());
1138 }
1139
1140 let primary = installed_command_path(current_exe, "codewhale");
1141 push_update_path(&mut target_paths, primary, current_exe);
1142
1143 for alias in ["codew", "codewhale-tui"] {
1144 let alias_path = installed_command_path(current_exe, alias);
1145 let migrate_legacy_tui = alias == "codewhale-tui"
1146 && is_legacy_binary(current_exe)
1147 && legacy_tui_command_exists_beside(current_exe);
1148 if std::fs::symlink_metadata(&alias_path).is_ok()
1149 || command_name_for_exe(current_exe) == alias
1150 || migrate_legacy_tui
1151 {
1152 push_update_path(&mut target_paths, alias_path, current_exe);
1153 }
1154 }
1155
1156 UpdatePlan {
1157 target_paths,
1158 asset_stem: release_asset_stem_for_prefix(
1159 "codewhale",
1160 std::env::consts::OS,
1161 std::env::consts::ARCH,
1162 ),
1163 }
1164 }
1165
1166 fn release_asset_stem_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
1167 let arch = release_arch_for_rust_arch(rust_arch);
1168 format!("{prefix}-{os}-{arch}")
1169 }
1170
1171 fn release_asset_name_for_prefix(prefix: &str, os: &str, rust_arch: &str) -> String {
1172 let stem = release_asset_stem_for_prefix(prefix, os, rust_arch);
1173 if os == "windows" {
1174 format!("{stem}.exe")
1175 } else {
1176 stem
1177 }
1178 }
1179
1180 #[cfg(test)]
1181 fn release_asset_stem_for(current_exe: &Path, os: &str, rust_arch: &str) -> String {
1182 let _ = current_exe;
1183 release_asset_stem_for_prefix("codewhale", os, rust_arch)
1184 }
1185
1186 pub(crate) fn asset_matches_platform(asset_name: &str, binary_name: &str) -> bool {
1187 if asset_name.ends_with(".sha256") {
1188 return false;
1189 }
1190 asset_name == binary_name
1191 || asset_name == format!("{binary_name}.exe")
1192 || asset_name.starts_with(&format!("{binary_name}."))
1193 }
1194
1195 fn asset_is_exact_platform_binary(asset_name: &str, binary_name: &str) -> bool {
1196 asset_name == binary_name || asset_name == format!("{binary_name}.exe")
1197 }
1198
1199 fn select_platform_asset<'a>(release: &'a Release, binary_name: &str) -> Option<&'a Asset> {
1200 release
1201 .assets
1202 .iter()
1203 .find(|asset| asset_is_exact_platform_binary(&asset.name, binary_name))
1204 .or_else(|| {
1205 release
1206 .assets
1207 .iter()
1208 .find(|asset| asset_matches_platform(&asset.name, binary_name))
1209 })
1210 }
1211
1212 fn select_checksum_manifest_asset(release: &Release) -> Option<&Asset> {
1213 release
1214 .assets
1215 .iter()
1216 .find(|asset| asset.name == CHECKSUM_MANIFEST_ASSET)
1217 }
1218
1219 fn parse_checksum_manifest(text: &str) -> Result<HashMap<String, String>> {
1220 let mut checksums = HashMap::new();
1221
1222 for (index, line) in text.lines().enumerate() {
1223 let trimmed = line.trim();
1224 if trimmed.is_empty() {
1225 continue;
1226 }
1227
1228 if trimmed.len() < 66 {
1229 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
1230 }
1231
1232 let (hash, rest) = trimmed.split_at(64);
1233 if !hash.chars().all(|ch| ch.is_ascii_hexdigit())
1234 || rest.is_empty()
1235 || !rest.chars().next().is_some_and(char::is_whitespace)
1236 {
1237 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
1238 }
1239
1240 let mut asset_name = rest.trim_start();
1241 if let Some(stripped) = asset_name.strip_prefix('*') {
1242 asset_name = stripped;
1243 }
1244 if asset_name.is_empty() {
1245 bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
1246 }
1247
1248 checksums.insert(asset_name.to_string(), hash.to_ascii_lowercase());
1249 }
1250
1251 Ok(checksums)
1252 }
1253
1254 #[cfg(test)]
1255 fn expected_sha256_from_manifest(text: &str, asset_name: &str) -> Result<String> {
1256 let checksums = parse_checksum_manifest(text)?;
1257 checksums
1258 .get(asset_name)
1259 .cloned()
1260 .with_context(|| format!("checksum manifest is missing {asset_name}"))
1261 }
1262
1263 /// GitHub release metadata.
1264 #[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
1265 struct Release {
1266 tag_name: String,
1267 #[serde(default)]
1268 prerelease: bool,
1269 assets: Vec<Asset>,
1270 }
1271
1272 /// A single release asset.
1273 #[derive(serde::Deserialize, Debug, Clone, PartialEq, Eq)]
1274 struct Asset {
1275 name: String,
1276 browser_download_url: String,
1277 }
1278
1279 /// Validate the proxy URL format and build a proxy for update HTTP requests.
1280 pub(crate) fn validate_and_build_proxy(proxy_str: &str) -> Result<Proxy> {
1281 let proxy_url = reqwest::Url::parse(proxy_str).with_context(|| {
1282 format!(
1283 "invalid proxy URL: {proxy_str}\n\
1284 Expected format: http://host:port, https://host:port, or socks5://host:port"
1285 )
1286 })?;
1287 Proxy::all(proxy_url).context("failed to configure update proxy")
1288 }
1289
1290 fn update_http_client(proxy: Option<&Proxy>) -> Result<reqwest::blocking::Client> {
1291 update_http_client_with_timeout(proxy, UPDATE_DOWNLOAD_TIMEOUT)
1292 }
1293
1294 fn update_http_client_with_timeout(
1295 proxy: Option<&Proxy>,
1296 timeout: Duration,
1297 ) -> Result<reqwest::blocking::Client> {
1298 let mut builder = codewhale_release::platform_blocking_http_client_builder();
1299 if let Some(proxy) = proxy {
1300 builder = builder.proxy(proxy.clone());
1301 }
1302 builder
1303 .user_agent(UPDATE_USER_AGENT)
1304 .timeout(timeout)
1305 .build()
1306 .context("failed to build update HTTP client")
1307 }
1308
1309 /// Fetch the latest release metadata from GitHub.
1310 fn fetch_latest_release(channel: ReleaseChannel, proxy: Option<&Proxy>) -> Result<FetchedRelease> {
1311 match resolve_release_query(channel) {
1312 ReleaseQuery::Mirror { base_url, version } => Ok(FetchedRelease {
1313 release: release_from_mirror_base_url(
1314 &base_url,
1315 &version,
1316 std::env::consts::OS,
1317 std::env::consts::ARCH,
1318 ),
1319 source: pinned_mirror_source(base_url),
1320 }),
1321 ReleaseQuery::GitHubLatest { url } => match fetch_latest_release_from_url(url, proxy) {
1322 Ok(release) => Ok(FetchedRelease {
1323 release,
1324 source: UpdateReleaseSource::GitHub,
1325 }),
1326 Err(api_error) => {
1327 eprintln!(
1328 "GitHub API release lookup failed; trying github.com releases/latest fallback..."
1329 );
1330 Ok(FetchedRelease {
1331 release: fetch_latest_stable_release_from_redirect(proxy).with_context(
1332 || format!("GitHub API release lookup failed first: {api_error:#}"),
1333 )?,
1334 source: UpdateReleaseSource::GitHub,
1335 })
1336 }
1337 },
1338 ReleaseQuery::GitHubReleaseList { url } => Ok(FetchedRelease {
1339 release: fetch_latest_beta_release_from_url(url, proxy)?,
1340 source: UpdateReleaseSource::GitHub,
1341 }),
1342 }
1343 }
1344
1345 /// Name the source an environment override selected.
1346 ///
1347 /// `CODEWHALE_USE_CNB_MIRROR` and `CODEWHALE_RELEASE_BASE_URL` both resolve to
1348 /// a base URL, but only the first is the first-party mirror — reporting them
1349 /// alike would hide which one a user actually asked for.
1350 fn pinned_mirror_source(base_url: String) -> UpdateReleaseSource {
1351 if cnb_mirror_override_active() {
1352 UpdateReleaseSource::Cnb { base_url }
1353 } else {
1354 UpdateReleaseSource::Mirror { base_url }
1355 }
1356 }
1357
1358 fn release_from_mirror_base_url(
1359 base_url: &str,
1360 version: &str,
1361 os: &str,
1362 rust_arch: &str,
1363 ) -> Release {
1364 let tag_name = format!("v{}", version.trim_start_matches('v'));
1365 release_from_asset_base_url(&tag_name, base_url, os, rust_arch)
1366 }
1367
1368 fn release_from_github_download_tag(tag_name: &str, os: &str, rust_arch: &str) -> Release {
1369 let tag_name = format!("v{}", tag_name.trim_start_matches('v'));
1370 let base_url = format!("{GITHUB_RELEASE_DOWNLOAD_BASE_URL}/{tag_name}");
1371 release_from_asset_base_url(&tag_name, &base_url, os, rust_arch)
1372 }
1373
1374 fn release_from_asset_base_url(
1375 tag_name: &str,
1376 base_url: &str,
1377 os: &str,
1378 rust_arch: &str,
1379 ) -> Release {
1380 let mut assets = vec![Asset {
1381 name: CHECKSUM_MANIFEST_ASSET.to_string(),
1382 browser_download_url: mirror_asset_url(base_url, CHECKSUM_MANIFEST_ASSET),
1383 }];
1384
1385 let name = release_asset_name_for_prefix("codewhale", os, rust_arch);
1386 assets.push(Asset {
1387 browser_download_url: mirror_asset_url(base_url, &name),
1388 name,
1389 });
1390
1391 Release {
1392 tag_name: tag_name.to_string(),
1393 prerelease: false,
1394 assets,
1395 }
1396 }
1397
1398 fn fetch_release_json_once(
1399 url: &str,
1400 description: &str,
1401 proxy: Option<&Proxy>,
1402 ) -> Result<(reqwest::StatusCode, String)> {
1403 let client = update_http_client(proxy)?;
1404 let response = client
1405 .get(url)
1406 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
1407 .send()
1408 .with_context(|| format!("failed to fetch {description} from {url}"))?;
1409 let status = response.status();
1410 let body = response
1411 .text()
1412 .with_context(|| format!("failed to read {description} response body from {url}"))?;
1413 Ok((status, body))
1414 }
1415
1416 fn fetch_release_json(url: &str, description: &str, proxy: Option<&Proxy>) -> Result<String> {
1417 let mut last_error = None;
1418 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
1419 match fetch_release_json_once(url, description, proxy) {
1420 Ok((status, body)) if status.is_success() => return Ok(body),
1421 Ok((status, body)) => {
1422 let error =
1423 anyhow!("failed to fetch {description} from {url}: HTTP {status}\n{body}");
1424 if should_retry_http_status(status) && attempt < UPDATE_HTTP_ATTEMPTS {
1425 last_error = Some(error);
1426 sleep_before_update_retry(attempt);
1427 continue;
1428 }
1429 return Err(error);
1430 }
1431 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1432 last_error = Some(error);
1433 sleep_before_update_retry(attempt);
1434 }
1435 Err(error) => return Err(error),
1436 }
1437 }
1438 Err(last_error.unwrap_or_else(|| anyhow!("failed to fetch {description} from {url}")))
1439 }
1440
1441 fn should_retry_http_status(status: reqwest::StatusCode) -> bool {
1442 status.is_server_error()
1443 || status == reqwest::StatusCode::REQUEST_TIMEOUT
1444 || status == reqwest::StatusCode::TOO_MANY_REQUESTS
1445 }
1446
1447 fn sleep_before_update_retry(attempt: usize) {
1448 std::thread::sleep(Duration::from_millis(
1449 UPDATE_HTTP_RETRY_DELAY_MS * attempt as u64,
1450 ));
1451 }
1452
1453 fn fetch_latest_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result<Release> {
1454 let body = fetch_release_json(url, "release info", proxy)?;
1455 let release: Release = serde_json::from_str(&body).with_context(|| {
1456 format!("failed to parse release JSON from GitHub API. Response: {body}")
1457 })?;
1458
1459 Ok(release)
1460 }
1461
1462 fn fetch_latest_stable_release_from_redirect(proxy: Option<&Proxy>) -> Result<Release> {
1463 let tag_name =
1464 fetch_latest_stable_tag_from_redirect_url(GITHUB_LATEST_RELEASE_PAGE_URL, proxy)?;
1465 Ok(release_from_github_download_tag(
1466 &tag_name,
1467 std::env::consts::OS,
1468 std::env::consts::ARCH,
1469 ))
1470 }
1471
1472 fn fetch_latest_stable_tag_from_redirect_url(url: &str, proxy: Option<&Proxy>) -> Result<String> {
1473 let client = update_http_client(proxy)?;
1474 let mut last_error = None;
1475 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
1476 match fetch_latest_stable_tag_from_redirect_url_once(&client, url) {
1477 Ok(tag_name) => return Ok(tag_name),
1478 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1479 last_error = Some(error);
1480 sleep_before_update_retry(attempt);
1481 }
1482 Err(error) => return Err(error),
1483 }
1484 }
1485 Err(last_error.unwrap_or_else(|| anyhow!("failed to resolve latest stable release from {url}")))
1486 }
1487
1488 fn fetch_latest_stable_tag_from_redirect_url_once(
1489 client: &reqwest::blocking::Client,
1490 url: &str,
1491 ) -> Result<String> {
1492 let response = client
1493 .get(url)
1494 .send()
1495 .with_context(|| format!("failed to fetch release redirect from {url}"))?;
1496 let status = response.status();
1497 let final_url = response.url().clone();
1498 if status.is_success() {
1499 if let Some(tag_name) = release_tag_from_github_release_url(&final_url) {
1500 return Ok(tag_name);
1501 }
1502 let body = response
1503 .text()
1504 .with_context(|| format!("failed to read release redirect response from {url}"))?;
1505 if let Some(tag_name) = release_tag_from_github_release_html(&body) {
1506 return Ok(tag_name);
1507 }
1508 bail!("release redirect did not resolve to a tag URL: {final_url}");
1509 }
1510
1511 let body = response
1512 .text()
1513 .with_context(|| format!("failed to read release redirect response from {url}"))?;
1514 bail!("failed to fetch release redirect from {url}: HTTP {status}\n{body}");
1515 }
1516
1517 fn release_tag_from_github_release_url(url: &reqwest::Url) -> Option<String> {
1518 let segments = url.path_segments()?.collect::<Vec<_>>();
1519 segments
1520 .windows(3)
1521 .find(|window| window[0] == "releases" && window[1] == "tag")
1522 .map(|window| window[2].to_string())
1523 .filter(|tag| !tag.is_empty())
1524 }
1525
1526 fn release_tag_from_github_release_html(body: &str) -> Option<String> {
1527 const MARKERS: &[&str] = &[
1528 "/Hmbown/CodeWhale/releases/tag/",
1529 "/hmbown/CodeWhale/releases/tag/",
1530 "/releases/tag/",
1531 ];
1532 for marker in MARKERS {
1533 for rest in body.split(marker).skip(1) {
1534 let tag = rest
1535 .split(['"', '\'', '<', '>', '?', '#', '&'])
1536 .next()
1537 .unwrap_or("")
1538 .trim();
1539 if !tag.is_empty() {
1540 return Some(tag.to_string());
1541 }
1542 }
1543 }
1544 None
1545 }
1546
1547 fn fetch_latest_beta_release_from_url(url: &str, proxy: Option<&Proxy>) -> Result<Release> {
1548 let body = fetch_release_json(url, "release list", proxy)?;
1549 // GitHub caps this endpoint at 100 releases per page. Codewhale uses the
1550 // first page as the latest-beta search window, matching GitHub's ordering.
1551 let releases: Vec<Release> = serde_json::from_str(&body).with_context(|| {
1552 format!("failed to parse release list JSON from GitHub API. Response: {body}")
1553 })?;
1554
1555 releases
1556 .into_iter()
1557 .find(|release| is_beta_tag(&release.tag_name))
1558 .context("no beta release found in GitHub releases")
1559 }
1560
1561 /// Download a URL to bytes.
1562 fn download_url(url: &str, proxy: Option<&Proxy>) -> Result<Vec<u8>> {
1563 download_url_with_timeout(url, proxy, UPDATE_DOWNLOAD_TIMEOUT)
1564 }
1565
1566 fn download_url_with_timeout(
1567 url: &str,
1568 proxy: Option<&Proxy>,
1569 timeout: Duration,
1570 ) -> Result<Vec<u8>> {
1571 let mut last_error = None;
1572 for attempt in 1..=UPDATE_HTTP_ATTEMPTS {
1573 match download_url_once(url, proxy, timeout) {
1574 Ok((status, bytes)) if status.is_success() => return Ok(bytes),
1575 Ok((status, bytes)) => {
1576 let body = String::from_utf8_lossy(&bytes);
1577 let error = anyhow!("download failed with HTTP {status}: {body}");
1578 if should_retry_http_status(status) && attempt < UPDATE_HTTP_ATTEMPTS {
1579 last_error = Some(error);
1580 sleep_before_update_retry(attempt);
1581 continue;
1582 }
1583 return Err(error);
1584 }
1585 Err(error) if attempt < UPDATE_HTTP_ATTEMPTS => {
1586 last_error = Some(error);
1587 sleep_before_update_retry(attempt);
1588 }
1589 Err(error) => return Err(error),
1590 }
1591 }
1592 Err(last_error.unwrap_or_else(|| anyhow!("failed to download {url}")))
1593 }
1594
1595 fn download_url_once(
1596 url: &str,
1597 proxy: Option<&Proxy>,
1598 timeout: Duration,
1599 ) -> Result<(reqwest::StatusCode, Vec<u8>)> {
1600 let client = update_http_client_with_timeout(proxy, timeout)?;
1601 let response = client
1602 .get(url)
1603 .send()
1604 .with_context(|| format!("failed to download {url}"))?;
1605 let status = response.status();
1606 let bytes = response
1607 .bytes()
1608 .with_context(|| format!("failed to read response body from {url}"))?;
1609
1610 Ok((status, bytes.to_vec()))
1611 }
1612
1613 /// Compute the SHA256 hex digest of data.
1614 fn sha256_hex(data: &[u8]) -> String {
1615 use sha2::Digest;
1616 let hash = sha2::Sha256::digest(data);
1617 hex_bytes(hash)
1618 }
1619
1620 fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
1621 let bytes = bytes.as_ref();
1622 let mut out = String::with_capacity(bytes.len() * 2);
1623 for byte in bytes {
1624 use std::fmt::Write as _;
1625 let _ = write!(&mut out, "{byte:02x}");
1626 }
1627 out
1628 }
1629
1630 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1631 struct GlibcVersion {
1632 major: u32,
1633 minor: u32,
1634 patch: u32,
1635 }
1636
1637 impl GlibcVersion {
1638 fn new(major: u32, minor: u32, patch: u32) -> Self {
1639 Self {
1640 major,
1641 minor,
1642 patch,
1643 }
1644 }
1645
1646 fn display(self) -> String {
1647 if self.patch == 0 {
1648 format!("{}.{}", self.major, self.minor)
1649 } else {
1650 format!("{}.{}.{}", self.major, self.minor, self.patch)
1651 }
1652 }
1653 }
1654
1655 fn parse_glibc_version(text: &str) -> Option<GlibcVersion> {
1656 text.split(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
1657 .filter(|part| part.contains('.'))
1658 .find_map(parse_glibc_version_token)
1659 }
1660
1661 fn parse_glibc_version_token(token: &str) -> Option<GlibcVersion> {
1662 let mut parts = token.split('.');
1663 let major = parts.next()?.parse().ok()?;
1664 let minor = parts.next()?.parse().ok()?;
1665 let patch = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0);
1666 Some(GlibcVersion::new(major, minor, patch))
1667 }
1668
1669 fn highest_required_glibc(bytes: &[u8]) -> Option<GlibcVersion> {
1670 const MARKER: &[u8] = b"GLIBC_";
1671 let mut offset = 0;
1672 let mut highest = None;
1673
1674 while let Some(found) = find_bytes(&bytes[offset..], MARKER) {
1675 let start = offset + found + MARKER.len();
1676 let mut end = start;
1677 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
1678 end += 1;
1679 }
1680 if end > start
1681 && let Ok(token) = std::str::from_utf8(&bytes[start..end])
1682 && let Some(version) = parse_glibc_version_token(token)
1683 && highest.is_none_or(|current| version > current)
1684 {
1685 highest = Some(version);
1686 }
1687 offset = start;
1688 }
1689
1690 highest
1691 }
1692
1693 fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1694 if needle.is_empty() || haystack.len() < needle.len() {
1695 return None;
1696 }
1697 haystack
1698 .windows(needle.len())
1699 .position(|window| window == needle)
1700 }
1701
1702 fn glibc_check_disabled() -> bool {
1703 [
1704 "CODEWHALE_SKIP_GLIBC_CHECK",
1705 "DEEPSEEK_TUI_SKIP_GLIBC_CHECK",
1706 "DEEPSEEK_SKIP_GLIBC_CHECK",
1707 ]
1708 .into_iter()
1709 .any(|name| std::env::var_os(name).is_some_and(|value| value == std::ffi::OsStr::new("1")))
1710 }
1711
1712 fn preflight_downloaded_binary(asset_name: &str, bytes: &[u8]) -> Result<()> {
1713 // GNU libc preflight is Linux-only (#4241). Rust treats `target_os = "android"`
1714 // as distinct from `"linux"`, so Termux/Android builds skip this check entirely
1715 // — Android uses Bionic libc, not glibc.
1716 if !cfg!(target_os = "linux") || glibc_check_disabled() {
1717 return Ok(());
1718 }
1719
1720 let Some(required) = highest_required_glibc(bytes) else {
1721 return Ok(());
1722 };
1723 let host = detect_host_glibc();
1724 if host.is_some_and(|host| host >= required) {
1725 return Ok(());
1726 }
1727
1728 bail!(
1729 "{}",
1730 glibc_compatibility_message(asset_name, required, host)
1731 );
1732 }
1733
1734 fn detect_host_glibc() -> Option<GlibcVersion> {
1735 let getconf = std::process::Command::new("getconf")
1736 .arg("GNU_LIBC_VERSION")
1737 .output()
1738 .ok()
1739 .filter(|output| output.status.success())
1740 .and_then(|output| String::from_utf8(output.stdout).ok())
1741 .and_then(|output| parse_glibc_version(&output));
1742 if getconf.is_some() {
1743 return getconf;
1744 }
1745
1746 std::process::Command::new("ldd")
1747 .arg("--version")
1748 .output()
1749 .ok()
1750 .filter(|output| output.status.success())
1751 .and_then(|output| {
1752 let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1753 if text.trim().is_empty() {
1754 text = String::from_utf8_lossy(&output.stderr).to_string();
1755 }
1756 parse_glibc_version(&text)
1757 })
1758 }
1759
1760 fn glibc_compatibility_message(
1761 asset_name: &str,
1762 required: GlibcVersion,
1763 host: Option<GlibcVersion>,
1764 ) -> String {
1765 let host_line = match host {
1766 Some(host) => format!(
1767 "this system has glibc {}, which is too old for that asset.",
1768 host.display()
1769 ),
1770 None => "this system does not appear to provide GNU libc.".to_string(),
1771 };
1772 format!(
1773 "\
1774 Prebuilt Codewhale asset `{asset_name}` requires GLIBC_{required}, but {host_line}
1775
1776 Official Linux release binaries are GNU libc builds. Ubuntu 22.04 ships glibc
1777 2.35, so it cannot run a binary that was built against Ubuntu 24.04/glibc 2.39.
1778
1779 Install from source on this host instead:
1780
1781 cargo install codewhale-cli --locked
1782
1783 Release engineering follow-up: build Linux GNU assets against an older glibc
1784 baseline, or add a musl/static Linux asset. Set CODEWHALE_SKIP_GLIBC_CHECK=1 to
1785 bypass this preflight at your own risk.",
1786 required = required.display(),
1787 )
1788 }
1789
1790 /// Replace the running binary.
1791 ///
1792 /// Writes the new binary to a secure temp file in the target directory, then
1793 /// installs it in place. Unix can atomically replace the executable path. On
1794 /// Windows, replacing a running executable can fail, so rename the current file
1795 /// out of the way before moving the new binary into the original path.
1796 #[cfg(test)]
1797 fn replace_binary(target: &Path, new_bytes: &[u8]) -> Result<()> {
1798 replace_binary_with_validation(target, new_bytes, || Ok(()))
1799 }
1800
1801 fn replace_binary_with_validation<F>(
1802 target: &Path,
1803 new_bytes: &[u8],
1804 validate_before_replace: F,
1805 ) -> Result<()>
1806 where
1807 F: FnOnce() -> Result<()>,
1808 {
1809 replace_binary_with_validation_and_permission_setter(
1810 target,
1811 new_bytes,
1812 validate_before_replace,
1813 |path, permissions| std::fs::set_permissions(path, permissions),
1814 )
1815 }
1816
1817 /// `apply_permissions` is a seam for `std::fs::set_permissions` so tests can
1818 /// exercise permission-setup failures without host-specific filesystem state.
1819 fn replace_binary_with_validation_and_permission_setter<F, P>(
1820 target: &Path,
1821 new_bytes: &[u8],
1822 validate_before_replace: F,
1823 apply_permissions: P,
1824 ) -> Result<()>
1825 where
1826 F: FnOnce() -> Result<()>,
1827 P: Fn(&Path, std::fs::Permissions) -> std::io::Result<()>,
1828 {
1829 let parent = target
1830 .parent()
1831 .filter(|path| !path.as_os_str().is_empty())
1832 .unwrap_or_else(|| Path::new("."));
1833
1834 let mut tmp = tempfile::Builder::new()
1835 .prefix(".codewhale-update-")
1836 .tempfile_in(parent)
1837 .with_context(|| format!("failed to create temp file in {}", parent.display()))?;
1838 tmp.write_all(new_bytes)
1839 .with_context(|| format!("failed to write temp file at {}", tmp.path().display()))?;
1840
1841 // Permission setup is part of pre-replacement validation: a staged binary
1842 // that cannot receive correct permissions must never replace a working
1843 // target, so every failure below aborts before any destructive rename.
1844 if target.exists() {
1845 // Preserve permissions from the original binary.
1846 let meta = std::fs::metadata(target).with_context(|| {
1847 format!(
1848 "failed to read permissions of update target {}",
1849 target.display()
1850 )
1851 })?;
1852 apply_permissions(tmp.path(), meta.permissions()).with_context(|| {
1853 format!(
1854 "failed to set permissions on staged update {} before replacing {}",
1855 tmp.path().display(),
1856 target.display()
1857 )
1858 })?;
1859 } else {
1860 #[cfg(unix)]
1861 {
1862 use std::os::unix::fs::PermissionsExt;
1863 apply_permissions(tmp.path(), std::fs::Permissions::from_mode(0o755)).with_context(
1864 || {
1865 format!(
1866 "failed to set permissions on staged update {} before installing {}",
1867 tmp.path().display(),
1868 target.display()
1869 )
1870 },
1871 )?;
1872 }
1873 }
1874
1875 // Independently verify the staged binary is executable before it may
1876 // replace the target; a chmod that silently did not stick would otherwise
1877 // install a binary that cannot run.
1878 #[cfg(unix)]
1879 {
1880 use std::os::unix::fs::PermissionsExt;
1881 let staged_mode = tmp
1882 .as_file()
1883 .metadata()
1884 .with_context(|| {
1885 format!(
1886 "failed to inspect staged update at {}",
1887 tmp.path().display()
1888 )
1889 })?
1890 .permissions()
1891 .mode();
1892 if staged_mode & 0o111 == 0 {
1893 bail!(
1894 "staged update {} is not executable (mode {:03o}); refusing to replace {}",
1895 tmp.path().display(),
1896 staged_mode & 0o7777,
1897 target.display()
1898 );
1899 }
1900 }
1901
1902 validate_before_replace()?;
1903
1904 #[cfg(windows)]
1905 {
1906 let backup = backup_path_for(target);
1907 if target.exists() {
1908 std::fs::rename(target, &backup).with_context(|| {
1909 format!(
1910 "failed to move current executable {} to {}",
1911 target.display(),
1912 backup.display()
1913 )
1914 })?;
1915 }
1916
1917 if let Err(err) = tmp.persist(target) {
1918 if backup.exists() {
1919 let _ = std::fs::rename(&backup, target);
1920 }
1921 bail!(
1922 "failed to install new binary at {}: {}",
1923 target.display(),
1924 err.error
1925 );
1926 }
1927
1928 let _ = std::fs::remove_file(&backup);
1929 }
1930
1931 #[cfg(not(windows))]
1932 {
1933 tmp.persist(target)
1934 .map_err(|err| err.error)
1935 .with_context(|| format!("failed to rename temp file to {}", target.display()))?;
1936 }
1937
1938 Ok(())
1939 }
1940
1941 #[cfg(windows)]
1942 fn backup_path_for(target: &Path) -> std::path::PathBuf {
1943 let pid = std::process::id();
1944 for index in 0..100 {
1945 let mut candidate = target.to_path_buf();
1946 let suffix = if index == 0 {
1947 format!("old-{pid}")
1948 } else {
1949 format!("old-{pid}-{index}")
1950 };
1951 candidate.set_extension(suffix);
1952 if !candidate.exists() {
1953 return candidate;
1954 }
1955 }
1956 target.with_extension(format!("old-{pid}-fallback"))
1957 }
1958
1959 #[cfg(test)]
1960 mod tests {
1961 use super::*;
1962 use std::ffi::OsString;
1963 use std::io::{Read, Write};
1964 use std::net::TcpListener;
1965 use std::sync::mpsc;
1966 use std::sync::{Mutex, MutexGuard};
1967 use std::thread;
1968
1969 /// Release-source environment variables are process-wide, so the tests that
1970 /// exercise override precedence take this lock and restore what they found.
1971 static UPDATE_ENV_LOCK: Mutex<()> = Mutex::new(());
1972 const UPDATE_ENV_VARS: &[&str] = &[
1973 codewhale_release::RELEASE_BASE_URL_ENV,
1974 codewhale_release::LEGACY_RELEASE_BASE_URL_ENV,
1975 codewhale_release::DEEPSEEK_RELEASE_BASE_URL_ENV,
1976 codewhale_release::CNB_MIRROR_ENV,
1977 codewhale_release::UPDATE_VERSION_ENV,
1978 codewhale_release::LEGACY_TUI_UPDATE_VERSION_ENV,
1979 codewhale_release::LEGACY_UPDATE_VERSION_ENV,
1980 codewhale_release::install::INSTALL_METHOD_ENV,
1981 ];
1982
1983 struct UpdateEnvGuard {
1984 previous: Vec<(&'static str, Option<OsString>)>,
1985 _lock: MutexGuard<'static, ()>,
1986 }
1987
1988 impl UpdateEnvGuard {
1989 fn clear() -> Self {
1990 let lock = UPDATE_ENV_LOCK
1991 .lock()
1992 .unwrap_or_else(|poisoned| poisoned.into_inner());
1993 let previous = UPDATE_ENV_VARS
1994 .iter()
1995 .map(|&name| (name, std::env::var_os(name)))
1996 .collect();
1997 for &name in UPDATE_ENV_VARS {
1998 // SAFETY: tests that mutate these process-wide vars hold UPDATE_ENV_LOCK.
1999 unsafe { std::env::remove_var(name) };
2000 }
2001 Self {
2002 previous,
2003 _lock: lock,
2004 }
2005 }
2006 }
2007
2008 impl Drop for UpdateEnvGuard {
2009 fn drop(&mut self) {
2010 for (name, value) in &self.previous {
2011 // SAFETY: the guard still holds UPDATE_ENV_LOCK while restoring state.
2012 unsafe {
2013 match value {
2014 Some(value) => std::env::set_var(name, value),
2015 None => std::env::remove_var(name),
2016 }
2017 }
2018 }
2019 }
2020 }
2021
2022 fn set_update_env(name: &str, value: &str) {
2023 // SAFETY: callers hold an UpdateEnvGuard, which serializes env mutation.
2024 unsafe { std::env::set_var(name, value) };
2025 }
2026
2027 #[cfg(unix)]
2028 fn write_test_executable(path: &Path) {
2029 std::fs::write(path, b"test executable").unwrap();
2030 use std::os::unix::fs::PermissionsExt;
2031 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2032 }
2033
2034 /// Write a stand-in installed binary: real update targets carry an
2035 /// executable mode on Unix, which the updater now preserves and verifies.
2036 fn write_installed_binary(path: &Path, bytes: &[u8]) {
2037 std::fs::write(path, bytes).unwrap();
2038 #[cfg(unix)]
2039 {
2040 use std::os::unix::fs::PermissionsExt;
2041 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2042 }
2043 }
2044
2045 /// Verify the arch mapping used when constructing asset names.
2046 /// The mapping must use release-asset naming (arm64/x64), not Rust
2047 /// stdlib constants (aarch64/x86_64).
2048 #[test]
2049 fn test_arch_mapping() {
2050 assert_eq!(release_arch_for_rust_arch("aarch64"), "arm64");
2051 assert_eq!(release_arch_for_rust_arch("x86_64"), "x64");
2052 // Pass-through for unknown arches
2053 assert_eq!(release_arch_for_rust_arch("riscv64"), "riscv64");
2054 // The currently-compiled arch maps to a release asset name
2055 let compiled_arch = std::env::consts::ARCH;
2056 let asset_arch = release_arch_for_rust_arch(compiled_arch);
2057 // Must not contain the raw Rust constant names
2058 assert!(
2059 !asset_arch.contains("aarch64") && !asset_arch.contains("x86_64"),
2060 "asset arch '{asset_arch}' still uses raw Rust constant name"
2061 );
2062 }
2063
2064 #[test]
2065 fn linux_riscv64_update_is_explicitly_unsupported() {
2066 let err = ensure_supported_release_target("linux", "riscv64")
2067 .expect_err("linux riscv64 should not claim a release asset");
2068 let message = err.to_string();
2069 assert!(message.contains("Linux riscv64 release assets are temporarily unavailable"));
2070 assert!(message.contains("rquickjs-sys 0.12.0"));
2071 ensure_supported_release_target("linux", "aarch64").unwrap();
2072 ensure_supported_release_target("macos", "aarch64").unwrap();
2073 }
2074
2075 #[cfg(unix)]
2076 const TEST_ANDROID_MARKER: u64 = 0x1800;
2077
2078 #[cfg(unix)]
2079 fn test_android_mapping_line(path: &Path, permissions: &str) -> String {
2080 use std::os::unix::fs::MetadataExt;
2081
2082 let metadata = std::fs::metadata(path).unwrap();
2083 let (device_major, device_minor) = android_device_parts(metadata.dev());
2084 format!(
2085 "1000-2000 {permissions} 00000000 {:x}:{:x} {} {}\n",
2086 device_major,
2087 device_minor,
2088 metadata.ino(),
2089 path.display()
2090 )
2091 }
2092
2093 #[cfg(unix)]
2094 #[test]
2095 fn android_loaded_image_resolves_agreed_mapping() {
2096 let dir = tempfile::TempDir::new().unwrap();
2097 let executable = dir.path().join("codewhale");
2098 write_test_executable(&executable);
2099 let maps = test_android_mapping_line(&executable, "r-xp");
2100
2101 let resolved =
2102 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
2103 .unwrap();
2104
2105 assert_eq!(resolved, executable.canonicalize().unwrap());
2106 assert_eq!(update_plan_for_exe(&resolved).target_paths[0], resolved);
2107 }
2108
2109 #[cfg(unix)]
2110 #[test]
2111 fn android_loaded_image_canonicalizes_symlink_and_sibling_policy() {
2112 use std::os::unix::fs::symlink;
2113
2114 let dir = tempfile::TempDir::new().unwrap();
2115 let canonical_dir = dir.path().join("canonical");
2116 let install_dir = dir.path().join("install");
2117 std::fs::create_dir(&canonical_dir).unwrap();
2118 std::fs::create_dir(&install_dir).unwrap();
2119 let canonical_dispatcher = canonical_dir.join("codewhale");
2120 let canonical_tui = canonical_dir.join("codewhale-tui");
2121 let invoked = install_dir.join("codewhale");
2122 write_test_executable(&canonical_dispatcher);
2123 write_test_executable(&canonical_tui);
2124 symlink(&canonical_dispatcher, &invoked).unwrap();
2125 let maps = test_android_mapping_line(&invoked, "r-xp");
2126
2127 let resolved =
2128 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &invoked).unwrap();
2129 let target_paths = update_plan_for_exe(&resolved).target_paths;
2130
2131 assert_eq!(
2132 target_paths,
2133 vec![
2134 canonical_dispatcher.canonicalize().unwrap(),
2135 canonical_tui.canonicalize().unwrap()
2136 ]
2137 );
2138 assert!(!target_paths.contains(&invoked));
2139 }
2140
2141 #[cfg(unix)]
2142 #[test]
2143 fn android_loaded_image_requires_marker_mapping() {
2144 let dir = tempfile::TempDir::new().unwrap();
2145 let executable = dir.path().join("codewhale");
2146 write_test_executable(&executable);
2147 let maps = test_android_mapping_line(&executable, "r-xp");
2148
2149 let error = resolve_android_loaded_executable_report(&maps, 0x3000, &executable)
2150 .expect_err("a marker outside every mapping must fail closed");
2151
2152 assert!(
2153 error.to_string().contains("no /proc/self/maps row"),
2154 "unexpected error: {error:#}"
2155 );
2156 }
2157
2158 #[cfg(unix)]
2159 #[test]
2160 fn android_loaded_image_requires_executable_mapping() {
2161 let dir = tempfile::TempDir::new().unwrap();
2162 let executable = dir.path().join("codewhale");
2163 write_test_executable(&executable);
2164 let maps = test_android_mapping_line(&executable, "rw-p");
2165
2166 let error =
2167 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
2168 .expect_err("a non-executable marker mapping must fail closed");
2169
2170 assert!(
2171 error
2172 .to_string()
2173 .contains("mapping for updater marker is not executable"),
2174 "unexpected error: {error:#}"
2175 );
2176 }
2177
2178 #[cfg(unix)]
2179 #[test]
2180 fn android_loaded_image_rejects_anonymous_mapping() {
2181 let dir = tempfile::TempDir::new().unwrap();
2182 let executable = dir.path().join("codewhale");
2183 write_test_executable(&executable);
2184 let maps = "1000-2000 r-xp 00000000 00:00 0\n";
2185
2186 let error =
2187 resolve_android_loaded_executable_report(maps, TEST_ANDROID_MARKER, &executable)
2188 .expect_err("an anonymous marker mapping must fail closed");
2189
2190 assert!(
2191 error.to_string().contains("has no file inode"),
2192 "unexpected error: {error:#}"
2193 );
2194 }
2195
2196 #[cfg(unix)]
2197 #[test]
2198 fn android_loaded_image_rejects_relative_or_deleted_paths() {
2199 let dir = tempfile::TempDir::new().unwrap();
2200 let executable = dir.path().join("codewhale");
2201 write_test_executable(&executable);
2202 let metadata = std::fs::metadata(&executable).unwrap();
2203 use std::os::unix::fs::MetadataExt;
2204 let (device_major, device_minor) = android_device_parts(metadata.dev());
2205 let relative_maps = format!(
2206 "1000-2000 r-xp 00000000 {:x}:{:x} {} codewhale\n",
2207 device_major,
2208 device_minor,
2209 metadata.ino()
2210 );
2211 let deleted = PathBuf::from(format!("{} (deleted)", executable.display()));
2212
2213 let relative_error = resolve_android_loaded_executable_report(
2214 &relative_maps,
2215 TEST_ANDROID_MARKER,
2216 &executable,
2217 )
2218 .expect_err("a relative maps pathname must fail closed");
2219 let deleted_error = resolve_android_loaded_executable_report(
2220 &test_android_mapping_line(&executable, "r-xp"),
2221 TEST_ANDROID_MARKER,
2222 &deleted,
2223 )
2224 .expect_err("a deleted dladdr pathname must fail closed");
2225
2226 assert!(relative_error.to_string().contains("non-absolute"));
2227 assert!(deleted_error.to_string().contains("deleted loaded image"));
2228 }
2229
2230 #[cfg(unix)]
2231 #[test]
2232 fn android_loaded_image_rejects_linker_and_symlink_to_linker() {
2233 use std::os::unix::fs::symlink;
2234
2235 let dir = tempfile::TempDir::new().unwrap();
2236 let runtime_linker = dir.path().join("linker64");
2237 let invoked = dir.path().join("codewhale");
2238 write_test_executable(&runtime_linker);
2239 symlink(&runtime_linker, &invoked).unwrap();
2240 let maps = test_android_mapping_line(&invoked, "r-xp");
2241
2242 let direct_error = resolve_android_loaded_executable_report(
2243 &maps,
2244 TEST_ANDROID_MARKER,
2245 Path::new("/system/bin/linker64"),
2246 )
2247 .expect_err("a directly reported Bionic linker must fail closed");
2248 let symlink_error =
2249 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &invoked)
2250 .expect_err("a symlink to a linker must fail closed");
2251
2252 assert!(
2253 direct_error
2254 .to_string()
2255 .contains("identifies runtime linker")
2256 );
2257 assert!(
2258 symlink_error
2259 .to_string()
2260 .contains("resolved to runtime linker")
2261 );
2262 }
2263
2264 #[cfg(unix)]
2265 #[test]
2266 fn android_linker_name_recognizes_bionic_loader_variants() {
2267 for name in [
2268 "linker",
2269 "linker64",
2270 "linker_asan",
2271 "linker_asan64",
2272 "linker_hwasan",
2273 "linker_hwasan64",
2274 ] {
2275 assert!(
2276 is_android_linker_name(
2277 Path::new("/apex/com.android.runtime/bin")
2278 .join(name)
2279 .as_path()
2280 ),
2281 "{name} must never become an updater target"
2282 );
2283 }
2284 assert!(!is_android_linker_name(Path::new("codewhale")));
2285 }
2286
2287 #[cfg(unix)]
2288 #[test]
2289 fn android_loaded_image_rejects_authority_disagreement() {
2290 let dir = tempfile::TempDir::new().unwrap();
2291 let mapped = dir.path().join("mapped-codewhale");
2292 let dladdr = dir.path().join("dladdr-codewhale");
2293 write_test_executable(&mapped);
2294 write_test_executable(&dladdr);
2295 let maps = test_android_mapping_line(&mapped, "r-xp");
2296
2297 let error = resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &dladdr)
2298 .expect_err("dladdr and maps path disagreement must fail closed");
2299
2300 assert!(
2301 error.to_string().contains("authorities disagree"),
2302 "unexpected error: {error:#}"
2303 );
2304 }
2305
2306 #[cfg(unix)]
2307 #[test]
2308 fn android_loaded_image_rejects_non_executable_file() {
2309 let dir = tempfile::TempDir::new().unwrap();
2310 let executable = dir.path().join("codewhale");
2311 std::fs::write(&executable, b"not executable").unwrap();
2312 let maps = test_android_mapping_line(&executable, "r-xp");
2313
2314 let error =
2315 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
2316 .expect_err("a non-executable target file must fail closed");
2317
2318 assert!(
2319 error.to_string().contains("not an executable regular file"),
2320 "unexpected error: {error:#}"
2321 );
2322 }
2323
2324 #[cfg(unix)]
2325 #[test]
2326 fn android_loaded_image_rejects_device_inode_mismatch() {
2327 let dir = tempfile::TempDir::new().unwrap();
2328 let executable = dir.path().join("codewhale");
2329 write_test_executable(&executable);
2330 let metadata = std::fs::metadata(&executable).unwrap();
2331 use std::os::unix::fs::MetadataExt;
2332 let (device_major, device_minor) = android_device_parts(metadata.dev());
2333 let maps = format!(
2334 "1000-2000 r-xp 00000000 {:x}:{:x} {} {}\n",
2335 device_major,
2336 device_minor,
2337 metadata.ino() + 1,
2338 executable.display()
2339 );
2340
2341 let error =
2342 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &executable)
2343 .expect_err("a different maps device/inode must fail closed");
2344
2345 assert!(
2346 error.to_string().contains("loaded-image identity changed"),
2347 "unexpected error: {error:#}"
2348 );
2349 }
2350
2351 #[cfg(unix)]
2352 #[test]
2353 fn android_loaded_image_recheck_detects_pre_replace_swap() {
2354 let dir = tempfile::TempDir::new().unwrap();
2355 let candidate = dir.path().join("codewhale");
2356 let replacement = dir.path().join("replacement");
2357 write_test_executable(&candidate);
2358 let maps = test_android_mapping_line(&candidate, "r-xp");
2359
2360 write_test_executable(&replacement);
2361 std::fs::rename(&replacement, &candidate).unwrap();
2362 let error =
2363 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &candidate)
2364 .expect_err("a path swap after download must fail before replacement");
2365
2366 assert!(
2367 error.to_string().contains("loaded-image identity changed"),
2368 "unexpected error: {error:#}"
2369 );
2370 }
2371
2372 #[cfg(unix)]
2373 #[test]
2374 fn android_identity_preflight_prevents_all_paired_replacements() {
2375 let dir = tempfile::TempDir::new().unwrap();
2376 let primary = dir.path().join("codewhale");
2377 let sibling = dir.path().join("codewhale-tui");
2378 let swapped_primary = dir.path().join("swapped-primary");
2379
2380 write_test_executable(&primary);
2381 std::fs::write(&primary, b"original running primary").unwrap();
2382 let maps = test_android_mapping_line(&primary, "r-xp");
2383 write_test_executable(&sibling);
2384 std::fs::write(&sibling, b"original sibling").unwrap();
2385 write_test_executable(&swapped_primary);
2386 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
2387 std::fs::rename(&swapped_primary, &primary).unwrap();
2388
2389 let target_paths = vec![primary.clone(), sibling.clone()];
2390 let error = replace_verified_downloads(&target_paths, b"downloaded binary", |_| {
2391 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
2392 .map(|_| ())
2393 })
2394 .expect_err("identity mismatch must fail before either binary changes");
2395
2396 assert!(
2397 error.to_string().contains("loaded-image identity changed"),
2398 "unexpected error: {error:#}"
2399 );
2400 assert_eq!(
2401 std::fs::read(&primary).unwrap(),
2402 b"externally swapped primary"
2403 );
2404 assert_eq!(std::fs::read(&sibling).unwrap(), b"original sibling");
2405 }
2406
2407 #[cfg(unix)]
2408 #[test]
2409 fn android_identity_recheck_before_sibling_prevents_pair_split() {
2410 use std::cell::Cell;
2411
2412 let dir = tempfile::TempDir::new().unwrap();
2413 let primary = dir.path().join("codewhale");
2414 let sibling = dir.path().join("codewhale-tui");
2415 let swapped_primary = dir.path().join("swapped-primary");
2416 write_test_executable(&primary);
2417 std::fs::write(&primary, b"original running primary").unwrap();
2418 let maps = test_android_mapping_line(&primary, "r-xp");
2419 write_test_executable(&sibling);
2420 std::fs::write(&sibling, b"original sibling").unwrap();
2421 write_test_executable(&swapped_primary);
2422 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
2423
2424 let target_paths = vec![primary.clone(), sibling.clone()];
2425 let validation_calls = Cell::new(0);
2426 let error = replace_verified_downloads(&target_paths, b"downloaded binary", |_| {
2427 let call = validation_calls.get() + 1;
2428 validation_calls.set(call);
2429 if call <= target_paths.len() {
2430 return Ok(());
2431 }
2432 std::fs::rename(&swapped_primary, &primary).unwrap();
2433 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
2434 .map(|_| ())
2435 })
2436 .expect_err("identity mismatch must fail before the staged sibling persists");
2437
2438 assert_eq!(validation_calls.get(), 3);
2439 assert!(
2440 error.to_string().contains("loaded-image identity changed"),
2441 "unexpected error: {error:#}"
2442 );
2443 assert_eq!(
2444 std::fs::read(&primary).unwrap(),
2445 b"externally swapped primary"
2446 );
2447 assert_eq!(std::fs::read(&sibling).unwrap(), b"original sibling");
2448 }
2449
2450 #[cfg(unix)]
2451 #[test]
2452 fn android_identity_jit_recheck_runs_after_staging_before_persist() {
2453 use std::cell::Cell;
2454
2455 let dir = tempfile::TempDir::new().unwrap();
2456 let primary = dir.path().join("codewhale");
2457 let swapped_primary = dir.path().join("swapped-primary");
2458 write_test_executable(&primary);
2459 std::fs::write(&primary, b"original running primary").unwrap();
2460 let maps = test_android_mapping_line(&primary, "r-xp");
2461 write_test_executable(&swapped_primary);
2462 std::fs::write(&swapped_primary, b"externally swapped primary").unwrap();
2463
2464 let target_paths = vec![primary.clone()];
2465 let validation_calls = Cell::new(0);
2466 let error = replace_verified_downloads(&target_paths, b"downloaded binary", |_| {
2467 let call = validation_calls.get() + 1;
2468 validation_calls.set(call);
2469 if call == 1 {
2470 return Ok(());
2471 }
2472 std::fs::rename(&swapped_primary, &primary).unwrap();
2473 resolve_android_loaded_executable_report(&maps, TEST_ANDROID_MARKER, &primary)
2474 .map(|_| ())
2475 })
2476 .expect_err("the post-staging identity swap must fail before persist");
2477
2478 assert_eq!(validation_calls.get(), 2);
2479 assert!(
2480 error.to_string().contains("loaded-image identity changed"),
2481 "unexpected error: {error:#}"
2482 );
2483 assert_eq!(
2484 std::fs::read(&primary).unwrap(),
2485 b"externally swapped primary"
2486 );
2487 assert!(
2488 std::fs::read_dir(dir.path()).unwrap().all(|entry| {
2489 !entry
2490 .unwrap()
2491 .file_name()
2492 .to_string_lossy()
2493 .starts_with(".codewhale-update-")
2494 }),
2495 "failed validation must clean the staged temp file"
2496 );
2497 }
2498
2499 /// Every command name resolves to the sole implementation asset.
2500 #[test]
2501 fn every_invocation_name_uses_codewhale_release_asset() {
2502 for command in [
2503 "codewhale",
2504 "codewhale.exe",
2505 "codew",
2506 "codew.exe",
2507 "codewhale-tui",
2508 "CodeWhale-TUI.exe",
2509 "deepseek",
2510 "deepseek-tui",
2511 "other-binary",
2512 ] {
2513 assert_eq!(
2514 release_asset_stem_for(Path::new(command), "macos", "aarch64"),
2515 "codewhale-macos-arm64"
2516 );
2517 }
2518 }
2519
2520 #[test]
2521 fn test_is_legacy_binary_detection() {
2522 assert!(is_legacy_binary(Path::new("deepseek")));
2523 assert!(is_legacy_binary(Path::new("deepseek-tui")));
2524 assert!(is_legacy_binary(Path::new("/usr/local/bin/deepseek")));
2525 assert!(is_legacy_binary(Path::new("/usr/local/bin/deepseek-tui")));
2526 assert!(is_legacy_binary(Path::new("DeepSeek.exe")));
2527 assert!(is_legacy_binary(Path::new("DeepSeek-TUI.exe")));
2528 assert!(!is_legacy_binary(Path::new("codewhale")));
2529 assert!(!is_legacy_binary(Path::new("codewhale-tui")));
2530 assert!(!is_legacy_binary(Path::new("codew")));
2531 }
2532
2533 #[test]
2534 fn managed_installs_offer_github_migration_and_secondary_manager_command() {
2535 let npm = managed_install_warning(InstallMethod::Npm).expect("npm is package-managed");
2536 assert!(npm.contains("npm install -g codewhale@latest"));
2537 assert!(npm.contains("in-place self-update is disabled"));
2538 assert!(npm.contains("https://codewhale.net/install.sh"));
2539 assert!(npm.contains("command -v codewhale codew"));
2540
2541 let brew =
2542 managed_install_warning(InstallMethod::Homebrew).expect("brew is package-managed");
2543 assert!(brew.contains("brew upgrade codewhale"));
2544
2545 assert!(managed_install_warning(InstallMethod::Cargo).is_some());
2546
2547 let omarchy =
2548 managed_install_warning(InstallMethod::Omarchy).expect("Omarchy is package-managed");
2549 assert!(omarchy.contains("omarchy update"));
2550
2551 // A plain release binary is exactly what this updater is for.
2552 assert!(managed_install_warning(InstallMethod::Binary).is_none());
2553 }
2554
2555 #[test]
2556 fn binary_override_cannot_authorize_a_known_package_install() {
2557 let _env = UpdateEnvGuard::clear();
2558 set_update_env(codewhale_release::install::INSTALL_METHOD_ENV, "binary");
2559 for (path, expected) in [
2560 (
2561 "/usr/local/lib/node_modules/codewhale/bin/codewhale",
2562 InstallMethod::Npm,
2563 ),
2564 (
2565 "/opt/homebrew/Cellar/codewhale/0.9.11/bin/codewhale",
2566 InstallMethod::Homebrew,
2567 ),
2568 ("/home/u/.cargo/bin/codewhale", InstallMethod::Cargo),
2569 ] {
2570 assert_eq!(InstallMethod::detect(Path::new(path)), expected);
2571 }
2572 }
2573
2574 #[test]
2575 fn explicit_mirror_cannot_downgrade_or_download_an_older_release() {
2576 let _env = UpdateEnvGuard::clear();
2577 set_update_env(
2578 codewhale_release::RELEASE_BASE_URL_ENV,
2579 "http://127.0.0.1:0",
2580 );
2581 set_update_env(codewhale_release::UPDATE_VERSION_ENV, "0.0.1");
2582 for beta in [false, true] {
2583 run_update(beta, false, None)
2584 .expect("an older pinned version must return before any download");
2585 }
2586 }
2587
2588 #[test]
2589 fn system_paths_are_protected_but_user_release_paths_are_allowed() {
2590 for path in [
2591 "/usr/bin/codewhale",
2592 "/usr/sbin/codewhale",
2593 "/bin/codewhale",
2594 "/nix/store/pkg/bin/codewhale",
2595 "/gnu/store/pkg/bin/codewhale",
2596 "/Users/u/scoop/apps/codewhale/codewhale.exe",
2597 "/Windows/System32/codewhale.exe",
2598 ] {
2599 assert!(protected_update_path(Path::new(path)), "{path}");
2600 }
2601 for path in [
2602 "/usr/local/bin/codewhale",
2603 "/home/u/.local/bin/codewhale",
2604 "/data/data/com.termux/files/usr/bin/codewhale",
2605 ] {
2606 assert!(!protected_update_path(Path::new(path)), "{path}");
2607 }
2608 }
2609
2610 #[cfg(not(target_os = "android"))]
2611 fn test_update_identity(path: &Path) -> UpdateExecutableIdentity {
2612 UpdateExecutableIdentity {
2613 path: path.to_path_buf(),
2614 file_hash: sha256_hex(&std::fs::read(path).unwrap()),
2615 }
2616 }
2617
2618 #[cfg(not(target_os = "android"))]
2619 #[test]
2620 fn unrelated_alias_fails_before_any_command_is_replaced() {
2621 let dir = tempfile::TempDir::new().unwrap();
2622 let primary = dir.path().join("codewhale");
2623 let alias = dir.path().join("codew");
2624 std::fs::write(&primary, b"running bytes").unwrap();
2625 std::fs::write(&alias, b"unrelated executable").unwrap();
2626 let identity = test_update_identity(&primary);
2627 let error =
2628 replace_verified_downloads(&[primary.clone(), alias.clone()], b"new bytes", |target| {
2629 validate_primary_update_identity(&identity)?;
2630 validate_update_target(target, &identity)
2631 })
2632 .unwrap_err();
2633 assert!(error.to_string().contains("bytes differ"), "{error:#}");
2634 assert_eq!(std::fs::read(primary).unwrap(), b"running bytes");
2635 assert_eq!(std::fs::read(alias).unwrap(), b"unrelated executable");
2636 }
2637
2638 #[cfg(not(target_os = "android"))]
2639 #[test]
2640 fn desktop_primary_swap_is_detected_before_siblings_change() {
2641 let dir = tempfile::TempDir::new().unwrap();
2642 let primary = dir.path().join("codewhale");
2643 let alias = dir.path().join("codew");
2644 for path in [&primary, &alias] {
2645 std::fs::write(path, b"running bytes").unwrap();
2646 }
2647 let identity = test_update_identity(&primary);
2648 std::fs::write(&primary, b"a different build").unwrap();
2649 let error =
2650 replace_verified_downloads(&[primary.clone(), alias.clone()], b"new bytes", |target| {
2651 validate_primary_update_identity(&identity)?;
2652 validate_update_target(target, &identity)
2653 })
2654 .unwrap_err();
2655 assert!(error.to_string().contains("path changed"), "{error:#}");
2656 assert_eq!(std::fs::read(primary).unwrap(), b"a different build");
2657 assert_eq!(std::fs::read(alias).unwrap(), b"running bytes");
2658 }
2659
2660 #[cfg(all(unix, not(target_os = "android")))]
2661 #[test]
2662 fn same_target_symlink_survives_and_foreign_or_broken_links_are_refused() {
2663 use std::os::unix::fs::symlink;
2664 let dir = tempfile::TempDir::new().unwrap();
2665 let primary = dir.path().canonicalize().unwrap().join("codewhale");
2666 let alias = primary.with_file_name("codew");
2667 write_installed_binary(&primary, b"running bytes");
2668 symlink("codewhale", &alias).unwrap();
2669 let identity = test_update_identity(&primary);
2670 let plan = update_plan_for_exe(&primary);
2671 assert_eq!(plan.target_paths.as_slice(), std::slice::from_ref(&primary));
2672 replace_verified_downloads(&plan.target_paths, b"new bytes", |target| {
2673 validate_primary_update_identity(&identity)?;
2674 validate_update_target(target, &identity)
2675 })
2676 .unwrap();
2677 assert!(std::fs::symlink_metadata(&alias).unwrap().is_symlink());
2678 assert_eq!(std::fs::read(&alias).unwrap(), b"new bytes");
2679 std::fs::remove_file(&alias).unwrap();
2680 symlink("foreign", &alias).unwrap();
2681 for exists in [false, true] {
2682 if exists {
2683 std::fs::write(primary.with_file_name("foreign"), b"other").unwrap();
2684 }
2685 let plan = update_plan_for_exe(&primary);
2686 assert!(plan.target_paths.contains(&alias));
2687 let identity = test_update_identity(&primary);
2688 assert!(validate_update_target(&alias, &identity).is_err());
2689 assert!(std::fs::symlink_metadata(&alias).unwrap().is_symlink());
2690 }
2691 }
2692
2693 #[test]
2694 fn legacy_binary_message_gives_copy_pasteable_migration_steps() {
2695 let message = legacy_binary_message(Path::new("/usr/local/bin/deepseek-tui"));
2696
2697 assert!(message.contains("legacy deepseek/deepseek-tui command name"));
2698 assert!(message.contains("canonical `codewhale` command"));
2699 assert!(message.contains("DeepSeek provider support"));
2700 assert!(message.contains("is unchanged"));
2701 assert!(message.contains(GITHUB_MIGRATION_HELP));
2702 assert!(!message.contains("This update will install"));
2703 assert!(message.contains("command -v codewhale codew"));
2704 assert!(message.contains("package manager"));
2705 assert!(!message.contains("uninstall"));
2706 assert!(message.contains("https://github.com/Hmbown/CodeWhale/releases/latest"));
2707 }
2708
2709 #[test]
2710 fn legacy_dispatcher_update_targets_canonical_compatibility_commands() {
2711 let dir = tempfile::TempDir::new().unwrap();
2712 let dispatcher = dir
2713 .path()
2714 .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX));
2715 let tui = dir
2716 .path()
2717 .join(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2718 std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
2719 std::fs::write(&tui, b"legacy tui").unwrap();
2720
2721 let plan = update_plan_for_exe(&dispatcher);
2722
2723 assert_eq!(
2724 plan.target_paths,
2725 vec![
2726 dir.path()
2727 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)),
2728 dir.path()
2729 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX))
2730 ]
2731 );
2732 assert!(plan.asset_stem.starts_with("codewhale-"));
2733 assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
2734 }
2735
2736 #[test]
2737 fn legacy_tui_update_targets_canonical_compatibility_commands() {
2738 let dir = tempfile::TempDir::new().unwrap();
2739 let dispatcher = dir
2740 .path()
2741 .join(format!("deepseek{}", std::env::consts::EXE_SUFFIX));
2742 let tui = dir
2743 .path()
2744 .join(format!("deepseek-tui{}", std::env::consts::EXE_SUFFIX));
2745 std::fs::write(&dispatcher, b"legacy dispatcher").unwrap();
2746 std::fs::write(&tui, b"legacy tui").unwrap();
2747
2748 let plan = update_plan_for_exe(&tui);
2749
2750 assert_eq!(
2751 plan.target_paths,
2752 vec![
2753 dir.path()
2754 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)),
2755 dir.path()
2756 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX))
2757 ]
2758 );
2759 assert!(plan.asset_stem.starts_with("codewhale-"));
2760 assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
2761 }
2762
2763 #[test]
2764 fn test_release_asset_stem_for_supported_platforms() {
2765 let cases = [
2766 ("codewhale", "macos", "aarch64", "codewhale-macos-arm64"),
2767 ("codewhale", "macos", "x86_64", "codewhale-macos-x64"),
2768 ("codewhale", "linux", "x86_64", "codewhale-linux-x64"),
2769 ("codewhale", "windows", "x86_64", "codewhale-windows-x64"),
2770 ("codewhale", "windows", "aarch64", "codewhale-windows-arm64"),
2771 ("codew", "macos", "aarch64", "codewhale-macos-arm64"),
2772 ("codewhale-tui", "linux", "x86_64", "codewhale-linux-x64"),
2773 ];
2774
2775 for (exe, os, arch, expected) in cases {
2776 assert_eq!(release_asset_stem_for(Path::new(exe), os, arch), expected);
2777 }
2778 }
2779
2780 #[test]
2781 fn update_plan_includes_existing_compatibility_tui_for_primary() {
2782 let dir = tempfile::TempDir::new().unwrap();
2783 let dispatcher = dir
2784 .path()
2785 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2786 let tui = dir
2787 .path()
2788 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2789 std::fs::write(&dispatcher, b"dispatcher").unwrap();
2790 std::fs::write(&tui, b"tui").unwrap();
2791
2792 let plan = update_plan_for_exe(&dispatcher);
2793 let paths = plan
2794 .target_paths
2795 .iter()
2796 .map(PathBuf::as_path)
2797 .collect::<Vec<_>>();
2798
2799 assert_eq!(paths, vec![dispatcher.as_path(), tui.as_path()]);
2800 assert!(plan.asset_stem.starts_with("codewhale-"));
2801 assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
2802 }
2803
2804 #[test]
2805 fn update_plan_skips_missing_compatibility_commands() {
2806 let dir = tempfile::TempDir::new().unwrap();
2807 let dispatcher = dir
2808 .path()
2809 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2810 std::fs::write(&dispatcher, b"dispatcher").unwrap();
2811
2812 let plan = update_plan_for_exe(&dispatcher);
2813
2814 assert_eq!(plan.target_paths, vec![dispatcher]);
2815 assert!(plan.asset_stem.starts_with("codewhale-"));
2816 }
2817
2818 #[test]
2819 fn v094_three_command_install_updates_every_path_from_primary_bytes() {
2820 let dir = tempfile::TempDir::new().unwrap();
2821 let primary = dir
2822 .path()
2823 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2824 let codew = dir
2825 .path()
2826 .join(format!("codew{}", std::env::consts::EXE_SUFFIX));
2827 let legacy_tui = dir
2828 .path()
2829 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2830 for path in [&primary, &codew, &legacy_tui] {
2831 write_installed_binary(path, b"v0.9.4 old bytes");
2832 }
2833
2834 let plan = update_plan_for_exe(&primary);
2835 assert_eq!(
2836 plan.target_paths,
2837 vec![primary.clone(), codew.clone(), legacy_tui.clone()]
2838 );
2839 assert!(plan.asset_stem.starts_with("codewhale-"));
2840 assert!(!plan.asset_stem.contains("codewhale-tui"));
2841
2842 replace_verified_downloads(&plan.target_paths, b"v0.9.5 primary bytes", |_| Ok(()))
2843 .unwrap();
2844
2845 for path in [&primary, &codew, &legacy_tui] {
2846 assert_eq!(std::fs::read(path).unwrap(), b"v0.9.5 primary bytes");
2847 }
2848 assert_ne!(std::fs::read(codew).unwrap(), b"v0.9.4 old bytes");
2849 }
2850
2851 #[test]
2852 fn direct_alias_invocation_keeps_running_path_first_and_updates_primary() {
2853 let dir = tempfile::TempDir::new().unwrap();
2854 let primary = dir
2855 .path()
2856 .join(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
2857 let codew = dir
2858 .path()
2859 .join(format!("codew{}", std::env::consts::EXE_SUFFIX));
2860 let legacy_tui = dir
2861 .path()
2862 .join(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX));
2863 for invoked in [&codew, &legacy_tui] {
2864 for path in [&primary, &codew, &legacy_tui] {
2865 write_installed_binary(path, b"old");
2866 }
2867 let plan = update_plan_for_exe(invoked);
2868 assert_eq!(plan.target_paths.first(), Some(invoked));
2869 assert!(plan.target_paths.contains(&primary));
2870 assert!(plan.target_paths.contains(&codew));
2871 assert!(plan.target_paths.contains(&legacy_tui));
2872 assert!(plan.asset_stem.starts_with("codewhale-"));
2873 assert!(!plan.asset_stem.starts_with("codewhale-tui-"));
2874
2875 replace_verified_downloads(&plan.target_paths, b"new primary bytes", |_| Ok(()))
2876 .unwrap();
2877 for path in [&primary, &codew, &legacy_tui] {
2878 assert_eq!(std::fs::read(path).unwrap(), b"new primary bytes");
2879 }
2880 }
2881 }
2882
2883 #[test]
2884 fn test_asset_matching_accepts_binary_assets_and_rejects_checksums() {
2885 assert!(asset_matches_platform(
2886 "codewhale-macos-arm64",
2887 "codewhale-macos-arm64"
2888 ));
2889 assert!(asset_matches_platform(
2890 "codewhale-macos-arm64.tar.gz",
2891 "codewhale-macos-arm64"
2892 ));
2893 assert!(asset_matches_platform(
2894 "codewhale-tui-windows-x64.exe",
2895 "codewhale-tui-windows-x64"
2896 ));
2897 assert!(!asset_matches_platform(
2898 "codewhale-tui-windows-x64.exe.sha256",
2899 "codewhale-tui-windows-x64"
2900 ));
2901 assert!(!asset_matches_platform(
2902 "codewhale-macos-aarch64.tar.gz",
2903 "codewhale-macos-arm64"
2904 ));
2905 }
2906
2907 #[test]
2908 fn select_platform_asset_prefers_bare_binary_over_archive() {
2909 let release = Release {
2910 tag_name: "v0.8.8".to_string(),
2911 prerelease: false,
2912 assets: vec![
2913 Asset {
2914 name: "codewhale-macos-arm64.tar.gz".to_string(),
2915 browser_download_url: "https://example.invalid/codewhale-macos-arm64.tar.gz"
2916 .to_string(),
2917 },
2918 Asset {
2919 name: "codewhale-macos-arm64".to_string(),
2920 browser_download_url: "https://example.invalid/codewhale-macos-arm64"
2921 .to_string(),
2922 },
2923 ],
2924 };
2925
2926 let asset =
2927 select_platform_asset(&release, "codewhale-macos-arm64").expect("platform asset");
2928
2929 assert_eq!(asset.name, "codewhale-macos-arm64");
2930 }
2931
2932 #[test]
2933 fn select_platform_asset_falls_back_to_archive_when_bare_binary_is_missing() {
2934 let release = Release {
2935 tag_name: "v0.8.8".to_string(),
2936 prerelease: false,
2937 assets: vec![Asset {
2938 name: "codewhale-macos-arm64.tar.gz".to_string(),
2939 browser_download_url: "https://example.invalid/codewhale-macos-arm64.tar.gz"
2940 .to_string(),
2941 }],
2942 };
2943
2944 let asset =
2945 select_platform_asset(&release, "codewhale-macos-arm64").expect("platform asset");
2946
2947 assert_eq!(asset.name, "codewhale-macos-arm64.tar.gz");
2948 }
2949
2950 #[test]
2951 fn test_sha256_hex_known_value() {
2952 let data = b"hello";
2953 let hash = sha256_hex(data);
2954 assert_eq!(
2955 hash,
2956 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_sha256_hex_empty() {
2962 let hash = sha256_hex(b"");
2963 assert_eq!(
2964 hash,
2965 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2966 );
2967 }
2968
2969 #[test]
2970 fn glibc_version_parser_reads_getconf_and_symbol_text() {
2971 assert_eq!(
2972 parse_glibc_version("glibc 2.35\n"),
2973 Some(GlibcVersion::new(2, 35, 0))
2974 );
2975 assert_eq!(
2976 parse_glibc_version("requires GLIBC_2.39"),
2977 Some(GlibcVersion::new(2, 39, 0))
2978 );
2979 assert_eq!(parse_glibc_version("not glibc"), None);
2980 }
2981
2982 #[test]
2983 fn highest_required_glibc_finds_highest_binary_symbol() {
2984 let bytes = b"\0GLIBC_2.17\0other\0GLIBC_2.39\0GLIBC_2.35";
2985
2986 assert_eq!(
2987 highest_required_glibc(bytes),
2988 Some(GlibcVersion::new(2, 39, 0))
2989 );
2990 }
2991
2992 #[test]
2993 fn glibc_compatibility_message_is_codewhale_branded_and_actionable() {
2994 let message = glibc_compatibility_message(
2995 "codewhale-linux-x64",
2996 GlibcVersion::new(2, 39, 0),
2997 Some(GlibcVersion::new(2, 35, 0)),
2998 );
2999
3000 assert!(message.contains("Prebuilt Codewhale asset `codewhale-linux-x64`"));
3001 assert!(message.contains("requires GLIBC_2.39"));
3002 assert!(message.contains("this system has glibc 2.35"));
3003 assert!(message.contains("cargo install codewhale-cli --locked"));
3004 assert!(message.contains("build Linux GNU assets against an older glibc"));
3005 }
3006
3007 #[test]
3008 fn parse_checksum_manifest_accepts_sha256sum_format() {
3009 let manifest = "\
3010 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 codewhale-macos-arm64
3011 E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-windows-x64.exe
3012 ";
3013 let checksums = parse_checksum_manifest(manifest).expect("valid manifest");
3014
3015 assert_eq!(
3016 checksums.get("codewhale-macos-arm64").map(String::as_str),
3017 Some("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
3018 );
3019 assert_eq!(
3020 checksums
3021 .get("codewhale-windows-x64.exe")
3022 .map(String::as_str),
3023 Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
3024 );
3025 }
3026
3027 #[test]
3028 fn parse_checksum_manifest_rejects_malformed_lines() {
3029 let err = parse_checksum_manifest("not-a-hash codewhale-macos-arm64")
3030 .expect_err("invalid manifest line should fail");
3031 assert!(
3032 err.to_string().contains("invalid SHA256 manifest line"),
3033 "unexpected error: {err:#}"
3034 );
3035 }
3036
3037 #[test]
3038 fn expected_sha256_from_manifest_requires_matching_asset() {
3039 let manifest =
3040 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 other-asset\n";
3041 let err = expected_sha256_from_manifest(manifest, "codewhale-macos-arm64")
3042 .expect_err("missing asset should fail");
3043 assert!(
3044 err.to_string()
3045 .contains("checksum manifest is missing codewhale-macos-arm64"),
3046 "unexpected error: {err:#}"
3047 );
3048 }
3049
3050 #[test]
3051 fn test_replace_binary_creates_and_replaces() {
3052 let dir = tempfile::TempDir::new().unwrap();
3053 let target = dir.path().join("codewhale-test");
3054 // Write initial content
3055 write_installed_binary(&target, b"old binary");
3056
3057 replace_binary(&target, b"new binary content").unwrap();
3058 let content = std::fs::read_to_string(&target).unwrap();
3059 assert_eq!(content, "new binary content");
3060 }
3061
3062 #[test]
3063 fn test_replace_binary_creates_new_file() {
3064 let dir = tempfile::TempDir::new().unwrap();
3065 let target = dir.path().join("codewhale-new-test");
3066
3067 replace_binary(&target, b"fresh binary").unwrap();
3068 let content = std::fs::read_to_string(&target).unwrap();
3069 assert_eq!(content, "fresh binary");
3070 }
3071
3072 fn assert_no_staged_temp_files(dir: &Path) {
3073 assert!(
3074 std::fs::read_dir(dir).unwrap().all(|entry| {
3075 !entry
3076 .unwrap()
3077 .file_name()
3078 .to_string_lossy()
3079 .starts_with(".codewhale-update-")
3080 }),
3081 "a failed permission setup must clean the staged temp file"
3082 );
3083 }
3084
3085 /// Regression test for #5727: a permission-setup failure on the staged
3086 /// binary must abort the update before the existing target is replaced.
3087 #[test]
3088 fn permission_failure_on_existing_target_aborts_before_replacement() {
3089 let dir = tempfile::TempDir::new().unwrap();
3090 let target = dir.path().join("codewhale-test");
3091 write_installed_binary(&target, b"old binary");
3092
3093 let error = replace_binary_with_validation_and_permission_setter(
3094 &target,
3095 b"new binary content",
3096 || Ok(()),
3097 |_, _| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
3098 )
3099 .expect_err("a chmod failure must fail the update");
3100
3101 assert!(
3102 error
3103 .to_string()
3104 .contains("failed to set permissions on staged update"),
3105 "unexpected error: {error:#}"
3106 );
3107 assert_eq!(
3108 std::fs::read(&target).unwrap(),
3109 b"old binary",
3110 "the working binary must survive a permission-setup failure"
3111 );
3112 assert_no_staged_temp_files(dir.path());
3113 }
3114
3115 /// Regression test for #5727, new-target path: when no binary exists yet
3116 /// the staged file still needs its 0o755 mode, and a chmod failure must
3117 /// abort instead of installing a non-executable file.
3118 #[cfg(unix)]
3119 #[test]
3120 fn permission_failure_on_new_target_aborts_install() {
3121 let dir = tempfile::TempDir::new().unwrap();
3122 let target = dir.path().join("codewhale-new-test");
3123
3124 let error = replace_binary_with_validation_and_permission_setter(
3125 &target,
3126 b"fresh binary",
3127 || Ok(()),
3128 |_, _| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
3129 )
3130 .expect_err("a chmod failure must fail a fresh install");
3131
3132 assert!(
3133 error
3134 .to_string()
3135 .contains("failed to set permissions on staged update"),
3136 "unexpected error: {error:#}"
3137 );
3138 assert!(
3139 !target.exists(),
3140 "a failed fresh install must not leave a target behind"
3141 );
3142 assert_no_staged_temp_files(dir.path());
3143 }
3144
3145 /// Regression test for #5727: even when permission setup reports success,
3146 /// a staged binary without an executable mode must never replace the
3147 /// working target.
3148 #[cfg(unix)]
3149 #[test]
3150 fn non_executable_staged_update_aborts_before_replacement() {
3151 let dir = tempfile::TempDir::new().unwrap();
3152 let target = dir.path().join("codewhale-test");
3153 write_test_executable(&target);
3154 std::fs::write(&target, b"old binary").unwrap();
3155
3156 // A no-op setter models a chmod that claims success without sticking,
3157 // leaving the staged temp file at its default non-executable 0o600.
3158 let error = replace_binary_with_validation_and_permission_setter(
3159 &target,
3160 b"new binary content",
3161 || Ok(()),
3162 |_, _| Ok(()),
3163 )
3164 .expect_err("a non-executable staged binary must fail the update");
3165
3166 assert!(
3167 error.to_string().contains("is not executable"),
3168 "unexpected error: {error:#}"
3169 );
3170 assert_eq!(
3171 std::fs::read(&target).unwrap(),
3172 b"old binary",
3173 "the working binary must survive a non-executable staged update"
3174 );
3175 assert_no_staged_temp_files(dir.path());
3176 }
3177
3178 /// Mocked GitHub release payload covering the sole implementation binary
3179 /// across the published platform/arch matrix, plus a checksum sibling that
3180 /// must never be picked as the binary.
3181 fn mocked_release() -> Release {
3182 let json = r#"{
3183 "tag_name": "v0.8.8",
3184 "assets": [
3185 { "name": "codewhale-linux-x64", "browser_download_url": "https://example.invalid/codewhale-linux-x64" },
3186 { "name": "codewhale-macos-x64", "browser_download_url": "https://example.invalid/codewhale-macos-x64" },
3187 { "name": "codewhale-macos-arm64", "browser_download_url": "https://example.invalid/codewhale-macos-arm64" },
3188 { "name": "codewhale-windows-x64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe" },
3189 { "name": "codewhale-windows-x64.exe.sha256", "browser_download_url": "https://example.invalid/codewhale-windows-x64.exe.sha256" },
3190 { "name": "codewhale-windows-arm64.exe", "browser_download_url": "https://example.invalid/codewhale-windows-arm64.exe" }
3191 ]
3192 }"#;
3193 serde_json::from_str(json).expect("mock release JSON")
3194 }
3195
3196 #[test]
3197 fn mocked_release_selects_dispatcher_asset_for_supported_platforms() {
3198 let release = mocked_release();
3199 let cases = [
3200 ("macos", "aarch64", "codewhale-macos-arm64"),
3201 ("macos", "x86_64", "codewhale-macos-x64"),
3202 ("linux", "x86_64", "codewhale-linux-x64"),
3203 ("windows", "x86_64", "codewhale-windows-x64.exe"),
3204 ("windows", "aarch64", "codewhale-windows-arm64.exe"),
3205 ];
3206
3207 for (os, arch, expected) in cases {
3208 let stem = release_asset_stem_for(Path::new("/usr/local/bin/codewhale"), os, arch);
3209 let asset = select_platform_asset(&release, &stem)
3210 .unwrap_or_else(|| panic!("no asset for {os}/{arch} (stem {stem})"));
3211 assert_eq!(asset.name, expected, "{os}/{arch}");
3212 }
3213 }
3214
3215 #[test]
3216 fn mocked_release_selects_primary_asset_when_compatibility_alias_invokes_update() {
3217 let release = mocked_release();
3218 let stem = release_asset_stem_for(
3219 Path::new("/usr/local/bin/codewhale-tui"),
3220 "macos",
3221 "aarch64",
3222 );
3223 let asset = select_platform_asset(&release, &stem).expect("primary platform asset");
3224 assert_eq!(asset.name, "codewhale-macos-arm64");
3225
3226 let windows_stem = release_asset_stem_for(Path::new("C:\\codew.exe"), "windows", "aarch64");
3227 let windows_asset =
3228 select_platform_asset(&release, &windows_stem).expect("Windows ARM64 primary asset");
3229 assert_eq!(windows_asset.name, "codewhale-windows-arm64.exe");
3230 }
3231
3232 #[test]
3233 fn android_arm64_maps_to_android_release_assets() {
3234 // The generic format!("{prefix}-{os}-{arch}") path naturally produces
3235 // Android asset stems. Verify every supported command name resolves to
3236 // the primary Android asset, never Linux or a removed TUI asset (#4241).
3237 assert_eq!(
3238 release_asset_stem_for_prefix("codewhale", "android", "aarch64"),
3239 "codewhale-android-arm64"
3240 );
3241 assert_eq!(
3242 release_asset_stem_for(Path::new("codewhale-tui"), "android", "aarch64"),
3243 "codewhale-android-arm64"
3244 );
3245 assert_eq!(
3246 release_asset_stem_for(Path::new("codew"), "android", "aarch64"),
3247 "codewhale-android-arm64"
3248 );
3249 }
3250
3251 #[test]
3252 fn ensure_supported_release_target_accepts_android() {
3253 // Android/Termux is a supported release target (#4241).
3254 assert!(ensure_supported_release_target("android", "aarch64").is_ok());
3255 }
3256
3257 #[test]
3258 fn android_release_assets_never_select_linux_arm64() {
3259 // Sanity: the stem formatter must never produce a linux-* stem for android.
3260 let stem = release_asset_stem_for_prefix("codewhale", "android", "aarch64");
3261 assert!(
3262 !stem.contains("linux"),
3263 "android stem must not contain linux: {stem}"
3264 );
3265 }
3266
3267 #[test]
3268 fn mirror_release_uses_base_url_and_platform_assets() {
3269 let release = release_from_mirror_base_url(
3270 "https://mirror.example/releases/v0.8.36/",
3271 "0.8.36",
3272 "linux",
3273 "x86_64",
3274 );
3275
3276 assert_eq!(release.tag_name, "v0.8.36");
3277 assert_eq!(release.assets[0].name, CHECKSUM_MANIFEST_ASSET);
3278 assert_eq!(
3279 release.assets[0].browser_download_url,
3280 "https://mirror.example/releases/v0.8.36/codewhale-artifacts-sha256.txt"
3281 );
3282
3283 let dispatcher =
3284 select_platform_asset(&release, "codewhale-linux-x64").expect("dispatcher asset");
3285 assert_eq!(
3286 dispatcher.browser_download_url,
3287 "https://mirror.example/releases/v0.8.36/codewhale-linux-x64"
3288 );
3289 assert_eq!(release.assets.len(), 2);
3290 assert!(
3291 select_platform_asset(&release, "codewhale-tui-linux-x64").is_none(),
3292 "mirror fallback must not synthesize a removed TUI asset"
3293 );
3294 }
3295
3296 #[test]
3297 fn mirror_release_uses_windows_exe_asset_names() {
3298 let release = release_from_mirror_base_url(
3299 "https://mirror.example/releases/v0.8.36",
3300 "v0.8.36",
3301 "windows",
3302 "x86_64",
3303 );
3304
3305 assert_eq!(release.tag_name, "v0.8.36");
3306 assert!(
3307 select_platform_asset(&release, "codewhale-windows-x64")
3308 .is_some_and(|asset| asset.name == "codewhale-windows-x64.exe")
3309 );
3310 assert!(select_platform_asset(&release, "codewhale-tui-windows-x64").is_none());
3311
3312 let arm_release = release_from_mirror_base_url(
3313 "https://mirror.example/releases/v0.9.1",
3314 "v0.9.1",
3315 "windows",
3316 "aarch64",
3317 );
3318 assert!(
3319 select_platform_asset(&arm_release, "codewhale-windows-arm64")
3320 .is_some_and(|asset| asset.name == "codewhale-windows-arm64.exe")
3321 );
3322 }
3323
3324 #[test]
3325 fn github_release_url_parser_extracts_tag() {
3326 let url = reqwest::Url::parse("https://github.com/Hmbown/CodeWhale/releases/tag/v0.8.61")
3327 .unwrap();
3328
3329 assert_eq!(
3330 release_tag_from_github_release_url(&url).as_deref(),
3331 Some("v0.8.61")
3332 );
3333 }
3334
3335 #[test]
3336 fn github_release_download_fallback_uses_deterministic_asset_urls() {
3337 let release = release_from_github_download_tag("0.8.61", "macos", "aarch64");
3338
3339 assert_eq!(release.tag_name, "v0.8.61");
3340 assert_eq!(
3341 release.assets[0].browser_download_url,
3342 "https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-artifacts-sha256.txt"
3343 );
3344 let dispatcher =
3345 select_platform_asset(&release, "codewhale-macos-arm64").expect("dispatcher asset");
3346 assert_eq!(
3347 dispatcher.browser_download_url,
3348 "https://github.com/Hmbown/CodeWhale/releases/download/v0.8.61/codewhale-macos-arm64"
3349 );
3350 assert_eq!(release.assets.len(), 2);
3351 assert!(select_platform_asset(&release, "codewhale-tui-macos-arm64").is_none());
3352 }
3353
3354 #[test]
3355 fn latest_stable_redirect_fallback_reads_tag_url() {
3356 let (url, request_rx, handle) = serve_http_once("200 OK", "text/html", b"<html></html>");
3357 let tag_url = url.replace("/release", "/Hmbown/CodeWhale/releases/tag/v9.9.9");
3358
3359 let tag = fetch_latest_stable_tag_from_redirect_url(&tag_url, None)
3360 .expect("tag should parse from final URL");
3361
3362 assert_eq!(tag, "v9.9.9");
3363 let request = request_rx.recv().expect("captured request");
3364 assert!(
3365 request.starts_with("GET /Hmbown/CodeWhale/releases/tag/v9.9.9 "),
3366 "got {request:?}"
3367 );
3368 handle.join().expect("test server thread");
3369 }
3370
3371 #[test]
3372 fn github_release_html_parser_skips_empty_first_marker() {
3373 let body = r#"
3374 <a href="/Hmbown/CodeWhale/releases/tag/?expanded=true">generic</a>
3375 <a href="/Hmbown/CodeWhale/releases/tag/v9.9.9">latest</a>
3376 "#;
3377
3378 assert_eq!(
3379 release_tag_from_github_release_html(body).as_deref(),
3380 Some("v9.9.9")
3381 );
3382 }
3383
3384 #[test]
3385 fn cnb_release_base_url_includes_tag_directory() {
3386 assert_eq!(
3387 codewhale_release::cnb_release_base_url("0.8.47"),
3388 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
3389 );
3390 assert_eq!(
3391 codewhale_release::cnb_release_base_url("v0.8.47"),
3392 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.8.47"
3393 );
3394 }
3395
3396 #[test]
3397 fn stable_update_is_needed_only_when_latest_is_newer() {
3398 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.46").unwrap());
3399 assert!(update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.9.0-beta.1").unwrap());
3400 assert!(!update_is_needed(ReleaseChannel::Stable, "0.8.45", "v0.8.45").unwrap());
3401 assert!(!update_is_needed(ReleaseChannel::Stable, "0.9.0", "v0.9.0-beta.1").unwrap());
3402 assert!(
3403 !update_is_needed(ReleaseChannel::Stable, "0.9.0-beta.2", "v0.9.0-beta.1").unwrap()
3404 );
3405 }
3406
3407 #[test]
3408 fn beta_update_allows_switching_from_same_stable_to_beta() {
3409 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0", "v1.0.0-beta.2").unwrap());
3410 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.2").unwrap());
3411 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.3", "v1.0.0-beta.2").unwrap());
3412 assert!(update_is_needed(ReleaseChannel::Beta, "1.0.0-beta.2", "v1.0.0-beta.3").unwrap());
3413 assert!(!update_is_needed(ReleaseChannel::Beta, "2.0.0", "v1.0.0-beta.3").unwrap());
3414 assert!(!update_is_needed(ReleaseChannel::Beta, "1.0.0-rc.1", "v1.0.0-beta.3").unwrap());
3415 }
3416
3417 #[test]
3418 fn parse_release_version_accepts_tags_and_build_suffixes() {
3419 assert_eq!(
3420 codewhale_release::parse_release_version("v0.9.0-beta.1").unwrap(),
3421 semver::Version::parse("0.9.0-beta.1").unwrap()
3422 );
3423 assert_eq!(
3424 codewhale_release::parse_release_version("0.8.45 (abcdef123456)").unwrap(),
3425 semver::Version::parse("0.8.45").unwrap()
3426 );
3427 }
3428
3429 #[test]
3430 fn beta_release_detection_requires_beta_tag() {
3431 let rc_prerelease = Release {
3432 tag_name: "v0.9.0-rc.1".to_string(),
3433 prerelease: true,
3434 assets: vec![],
3435 };
3436 let beta_tag = Release {
3437 tag_name: "v0.9.0-beta.1".to_string(),
3438 prerelease: false,
3439 assets: vec![],
3440 };
3441 let stable = Release {
3442 tag_name: "v0.9.0".to_string(),
3443 prerelease: false,
3444 assets: vec![],
3445 };
3446
3447 assert!(!is_beta_tag(&rc_prerelease.tag_name));
3448 assert!(is_beta_tag(&beta_tag.tag_name));
3449 assert!(!is_beta_tag(&stable.tag_name));
3450 }
3451
3452 #[test]
3453 fn update_fallback_hint_points_china_users_to_cnb_and_asset_mirrors() {
3454 let hint = update_network_fallback_hint();
3455
3456 assert!(hint.contains(codewhale_release::CNB_REPO_URL), "{hint}");
3457 assert!(
3458 hint.contains(codewhale_release::RELEASE_BASE_URL_ENV),
3459 "{hint}"
3460 );
3461 assert!(
3462 hint.contains(codewhale_release::UPDATE_VERSION_ENV),
3463 "{hint}"
3464 );
3465 assert!(hint.contains("codewhale-cli"), "{hint}");
3466 assert!(!hint.contains("codewhale-tui --locked"), "{hint}");
3467 }
3468
3469 fn serve_http_responses(
3470 responses: Vec<(&'static str, &'static str, &'static [u8])>,
3471 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
3472 serve_http_owned_responses(
3473 responses
3474 .into_iter()
3475 .map(|(status, content_type, body)| (status, content_type, body.to_vec()))
3476 .collect(),
3477 )
3478 }
3479
3480 fn serve_http_owned_responses(
3481 responses: Vec<(&'static str, &'static str, Vec<u8>)>,
3482 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
3483 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
3484 let addr = listener.local_addr().expect("test server addr");
3485 let (request_tx, request_rx) = mpsc::channel();
3486
3487 let handle = thread::spawn(move || {
3488 for (status, content_type, body) in responses {
3489 let (mut stream, _) = listener.accept().expect("accept test request");
3490 let mut buf = [0_u8; 4096];
3491 let n = stream.read(&mut buf).expect("read test request");
3492 request_tx
3493 .send(String::from_utf8_lossy(&buf[..n]).to_string())
3494 .expect("send captured request");
3495
3496 write!(
3497 stream,
3498 "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
3499 body.len()
3500 )
3501 .expect("write test response headers");
3502 stream.write_all(&body).expect("write test response body");
3503 }
3504 });
3505
3506 (format!("http://{addr}/release"), request_rx, handle)
3507 }
3508
3509 fn serve_http_once(
3510 status: &'static str,
3511 content_type: &'static str,
3512 body: &'static [u8],
3513 ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
3514 serve_http_responses(vec![(status, content_type, body)])
3515 }
3516
3517 // Ordered source selection is deterministic and offline. A successful
3518 // GitHub manifest must not cause any mirror request.
3519 fn scripted_manifest_fetcher(
3520 script: Vec<(&'static str, Result<String, String>)>,
3521 ) -> Arc<ManifestFetcher> {
3522 let script: HashMap<_, _> = script.into_iter().collect();
3523 Arc::new(move |candidate: &ReleaseSourceCandidate| {
3524 script
3525 .get(candidate.source.label())
3526 .expect("unexpected source request")
3527 .clone()
3528 .map(String::into_bytes)
3529 .map_err(|message| anyhow!(message))
3530 })
3531 }
3532
3533 fn manifest_covering_linux_x64() -> String {
3534 format!(
3535 "{} codewhale-linux-x64\n{} codew-linux-x64\n",
3536 "a".repeat(64),
3537 "b".repeat(64)
3538 )
3539 }
3540
3541 fn manifest_missing_linux_x64() -> String {
3542 format!("{} codewhale-macos-arm64\n", "c".repeat(64))
3543 }
3544
3545 fn github_fetched_release(tag_name: &str) -> FetchedRelease {
3546 FetchedRelease {
3547 release: Release {
3548 tag_name: tag_name.to_string(),
3549 prerelease: is_beta_tag(tag_name),
3550 assets: vec![
3551 Asset {
3552 name: "codewhale-linux-x64".to_string(),
3553 browser_download_url: format!(
3554 "https://github.com/Hmbown/CodeWhale/releases/download/{tag_name}/codewhale-linux-x64"
3555 ),
3556 },
3557 Asset {
3558 name: CHECKSUM_MANIFEST_ASSET.to_string(),
3559 browser_download_url: format!(
3560 "https://github.com/Hmbown/CodeWhale/releases/download/{tag_name}/{CHECKSUM_MANIFEST_ASSET}"
3561 ),
3562 },
3563 ],
3564 },
3565 source: UpdateReleaseSource::GitHub,
3566 }
3567 }
3568
3569 fn candidates_for(
3570 fetched: &FetchedRelease,
3571 os: &str,
3572 arch: &str,
3573 ) -> Option<Vec<ReleaseSourceCandidate>> {
3574 proactive_source_candidates(fetched, "codewhale-linux-x64", os, arch)
3575 }
3576
3577 fn linux_x64_candidates(tag_name: &str) -> Vec<ReleaseSourceCandidate> {
3578 candidates_for(&github_fetched_release(tag_name), "linux", "x86_64")
3579 .expect("linux x64 must try GitHub before the CNB mirror")
3580 }
3581
3582 #[test]
3583 fn github_is_preferred_without_contacting_a_healthy_mirror() {
3584 let requested = Arc::new(Mutex::new(Vec::new()));
3585 let captured = Arc::clone(&requested);
3586 let fetch: Arc<ManifestFetcher> = Arc::new(move |candidate| {
3587 captured.lock().unwrap().push(candidate.source.label());
3588 Ok(manifest_covering_linux_x64().into_bytes())
3589 });
3590 let plan = select_release_source(linux_x64_candidates("v9.9.9"), fetch).unwrap();
3591 assert_eq!(plan.source, UpdateReleaseSource::GitHub);
3592 assert_eq!(*requested.lock().unwrap(), ["GitHub Releases"]);
3593 assert_eq!(
3594 plan.binary_url,
3595 "https://github.com/Hmbown/CodeWhale/releases/download/v9.9.9/codewhale-linux-x64"
3596 );
3597 }
3598
3599 #[test]
3600 fn cnb_is_used_only_after_github_manifest_failure() {
3601 for first_answer in [
3602 Err("connection timed out".to_string()),
3603 Ok(manifest_missing_linux_x64()),
3604 Ok("not a checksum manifest".to_string()),
3605 Ok(String::new()),
3606 ] {
3607 let requested = Arc::new(Mutex::new(Vec::new()));
3608 let captured = Arc::clone(&requested);
3609 let fetch: Arc<ManifestFetcher> = Arc::new(move |candidate| {
3610 captured.lock().unwrap().push(candidate.source.label());
3611 if candidate.source == UpdateReleaseSource::GitHub {
3612 first_answer
3613 .clone()
3614 .map(String::into_bytes)
3615 .map_err(|err| anyhow!(err))
3616 } else {
3617 Ok(manifest_covering_linux_x64().into_bytes())
3618 }
3619 });
3620 let plan = select_release_source(linux_x64_candidates("v9.9.9"), fetch).unwrap();
3621 assert_eq!(
3622 *requested.lock().unwrap(),
3623 ["GitHub Releases", "CNB mirror"]
3624 );
3625 assert_eq!(
3626 plan.source,
3627 UpdateReleaseSource::Cnb {
3628 base_url: cnb_release_base_url("v9.9.9"),
3629 }
3630 );
3631 assert_eq!(
3632 plan.binary_url,
3633 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v9.9.9/codewhale-linux-x64"
3634 );
3635 assert_eq!(
3636 plan.checksums.get("codewhale-linux-x64"),
3637 Some(&"a".repeat(64))
3638 );
3639 }
3640 }
3641
3642 #[test]
3643 fn selection_fails_closed_when_no_source_is_usable() {
3644 let fetch = scripted_manifest_fetcher(vec![
3645 ("GitHub Releases", Err("dns failure".to_string())),
3646 ("CNB mirror", Ok(manifest_missing_linux_x64())),
3647 ]);
3648
3649 let err = select_release_source(linux_x64_candidates("v9.9.9"), fetch)
3650 .expect_err("no usable source must fail rather than download unverified bytes");
3651 let message = format!("{err:#}");
3652
3653 assert!(
3654 message.contains("no release source published a usable"),
3655 "unexpected error: {message}"
3656 );
3657 assert!(
3658 message.contains("dns failure"),
3659 "unexpected error: {message}"
3660 );
3661 assert!(
3662 message.contains("does not list codewhale-linux-x64"),
3663 "the unusable manifest must be reported as unusable: {message}"
3664 );
3665 assert!(
3666 message.contains("GitHub Releases") && message.contains("CNB mirror"),
3667 "both failures must be attributed: {message}"
3668 );
3669 }
3670
3671 #[test]
3672 fn only_supported_targets_have_cnb_as_a_fallback() {
3673 let fetched = github_fetched_release("v9.9.9");
3674
3675 for (os, arch) in [
3676 ("linux", "aarch64"),
3677 ("linux", "riscv64"),
3678 ("macos", "x86_64"),
3679 ("macos", "aarch64"),
3680 ("windows", "x86_64"),
3681 ("android", "aarch64"),
3682 ] {
3683 assert!(
3684 candidates_for(&fetched, os, arch).is_none(),
3685 "{os}/{arch} must keep its single canonical source"
3686 );
3687 }
3688
3689 let raced = candidates_for(&fetched, "linux", "x86_64").expect("linux x64 fallback");
3690 assert_eq!(raced.len(), 2);
3691 }
3692
3693 #[test]
3694 fn cnb_candidate_targets_the_exact_tag_and_platform_asset() {
3695 let candidate = cnb_source_candidate("v0.9.0-beta.2", "linux", "x86_64");
3696
3697 assert_eq!(
3698 candidate.source,
3699 UpdateReleaseSource::Cnb {
3700 base_url: cnb_release_base_url("v0.9.0-beta.2"),
3701 }
3702 );
3703 assert_eq!(
3704 candidate.manifest_url,
3705 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.9.0-beta.2/codewhale-artifacts-sha256.txt"
3706 );
3707 assert_eq!(
3708 candidate.binary_url,
3709 "https://cnb.cool/codewhale.net/codewhale/-/releases/download/v0.9.0-beta.2/codewhale-linux-x64"
3710 );
3711 assert_eq!(candidate.binary_name, "codewhale-linux-x64");
3712 }
3713
3714 #[test]
3715 fn github_candidate_addresses_the_manifest_even_when_the_payload_omits_it() {
3716 let release = Release {
3717 tag_name: "0.9.9".to_string(),
3718 prerelease: false,
3719 assets: vec![Asset {
3720 name: "codewhale-linux-x64".to_string(),
3721 browser_download_url: "https://cdn.example/codewhale-linux-x64".to_string(),
3722 }],
3723 };
3724
3725 let candidate =
3726 github_source_candidate(&release, "codewhale-linux-x64").expect("github candidate");
3727
3728 assert_eq!(
3729 candidate.manifest_url,
3730 "https://github.com/Hmbown/CodeWhale/releases/download/v0.9.9/codewhale-artifacts-sha256.txt"
3731 );
3732 assert_eq!(
3733 candidate.binary_url,
3734 "https://cdn.example/codewhale-linux-x64"
3735 );
3736 }
3737
3738 #[test]
3739 fn every_single_source_platform_requires_a_checksum_manifest() {
3740 for (os, arch) in [
3741 ("linux", "x86_64"),
3742 ("linux", "aarch64"),
3743 ("macos", "x86_64"),
3744 ("macos", "aarch64"),
3745 ("windows", "x86_64"),
3746 ("windows", "aarch64"),
3747 ("android", "aarch64"),
3748 ] {
3749 let asset_stem = release_asset_stem_for_prefix("codewhale", os, arch);
3750 let asset_name = release_asset_name_for_prefix("codewhale", os, arch);
3751 let fetched = FetchedRelease {
3752 release: Release {
3753 tag_name: "v9.9.9".to_string(),
3754 prerelease: false,
3755 assets: vec![Asset {
3756 name: asset_name.clone(),
3757 browser_download_url: format!("https://cdn.example/{asset_name}"),
3758 }],
3759 },
3760 source: UpdateReleaseSource::GitHub,
3761 };
3762
3763 let err = single_source_download_plan(&fetched, &asset_stem, None)
3764 .expect_err("a missing manifest must fail before the binary download");
3765 let message = format!("{err:#}");
3766 assert!(
3767 message.contains("does not publish required codewhale-artifacts-sha256.txt"),
3768 "{os}/{arch} unexpectedly allowed an unverifiable plan: {message}"
3769 );
3770 assert!(message.contains(&asset_name), "{os}/{arch}: {message}");
3771 }
3772 }
3773
3774 #[test]
3775 fn a_malformed_single_source_manifest_fails_before_binary_download() {
3776 let (manifest_url, request_rx, handle) =
3777 serve_http_once("200 OK", "text/plain", b"not a checksum manifest\n");
3778 let fetched = FetchedRelease {
3779 release: Release {
3780 tag_name: "v9.9.9".to_string(),
3781 prerelease: false,
3782 assets: vec![
3783 Asset {
3784 name: CHECKSUM_MANIFEST_ASSET.to_string(),
3785 browser_download_url: manifest_url,
3786 },
3787 Asset {
3788 name: "codewhale-macos-arm64".to_string(),
3789 browser_download_url: "https://cdn.example/should-not-download".to_string(),
3790 },
3791 ],
3792 },
3793 source: UpdateReleaseSource::GitHub,
3794 };
3795
3796 let err = single_source_download_plan(&fetched, "codewhale-macos-arm64", None)
3797 .expect_err("a malformed manifest must fail closed");
3798 let message = format!("{err:#}");
3799 assert!(message.contains("failed to parse"), "{message}");
3800 assert!(
3801 message.contains("invalid SHA256 manifest line"),
3802 "{message}"
3803 );
3804 let request = request_rx.recv().expect("manifest request");
3805 assert!(request.starts_with("GET /release "), "got {request:?}");
3806 handle.join().expect("test server thread");
3807 }
3808
3809 #[test]
3810 fn a_single_source_manifest_must_cover_the_exact_platform_binary() {
3811 let manifest = format!("{} codewhale-linux-x64\n", "a".repeat(64));
3812 let (manifest_url, request_rx, handle) =
3813 serve_http_owned_responses(vec![("200 OK", "text/plain", manifest.into_bytes())]);
3814 let fetched = FetchedRelease {
3815 release: Release {
3816 tag_name: "v9.9.9".to_string(),
3817 prerelease: false,
3818 assets: vec![
3819 Asset {
3820 name: CHECKSUM_MANIFEST_ASSET.to_string(),
3821 browser_download_url: manifest_url,
3822 },
3823 Asset {
3824 name: "codewhale-windows-x64.exe".to_string(),
3825 browser_download_url: "https://cdn.example/should-not-download.exe"
3826 .to_string(),
3827 },
3828 ],
3829 },
3830 source: UpdateReleaseSource::GitHub,
3831 };
3832
3833 let err = single_source_download_plan(&fetched, "codewhale-windows-x64", None)
3834 .expect_err("a manifest for another platform must fail closed");
3835 let message = format!("{err:#}");
3836 assert!(
3837 message.contains("does not list codewhale-windows-x64.exe"),
3838 "{message}"
3839 );
3840 let request = request_rx.recv().expect("manifest request");
3841 assert!(request.starts_with("GET /release "), "got {request:?}");
3842 handle.join().expect("test server thread");
3843 }
3844
3845 #[test]
3846 fn an_explicit_mirror_remains_pinned_and_verified_from_that_mirror() {
3847 let bytes = b"verified mirror bytes";
3848 let manifest = format!("{} codewhale-macos-arm64\n", sha256_hex(bytes));
3849 let (url, request_rx, handle) =
3850 serve_http_owned_responses(vec![("200 OK", "text/plain", manifest.into_bytes())]);
3851 let base_url = url.trim_end_matches("/release").to_string();
3852 let fetched = FetchedRelease {
3853 release: release_from_mirror_base_url(&base_url, "9.9.9", "macos", "aarch64"),
3854 source: UpdateReleaseSource::Mirror {
3855 base_url: base_url.clone(),
3856 },
3857 };
3858
3859 let plan = single_source_download_plan(&fetched, "codewhale-macos-arm64", None)
3860 .expect("the explicit mirror's valid manifest should produce a plan");
3861 assert_eq!(
3862 plan.source,
3863 UpdateReleaseSource::Mirror {
3864 base_url: base_url.clone(),
3865 }
3866 );
3867 assert_eq!(
3868 plan.binary_url,
3869 mirror_asset_url(&base_url, "codewhale-macos-arm64")
3870 );
3871 verify_downloaded_asset(&plan, bytes)
3872 .expect("the pinned mirror's checksum must verify its bytes");
3873 let request = request_rx.recv().expect("manifest request");
3874 assert!(
3875 request.starts_with("GET /codewhale-artifacts-sha256.txt "),
3876 "got {request:?}"
3877 );
3878 handle.join().expect("test server thread");
3879 }
3880
3881 #[test]
3882 fn a_github_release_without_this_platform_leaves_cnb_as_the_only_candidate() {
3883 let fetched = FetchedRelease {
3884 release: Release {
3885 tag_name: "v9.9.9".to_string(),
3886 prerelease: false,
3887 assets: vec![Asset {
3888 name: "codewhale-macos-arm64".to_string(),
3889 browser_download_url: "https://cdn.example/codewhale-macos-arm64".to_string(),
3890 }],
3891 },
3892 source: UpdateReleaseSource::GitHub,
3893 };
3894
3895 let candidates = candidates_for(&fetched, "linux", "x86_64").expect("linux x64 fallback");
3896
3897 assert_eq!(candidates.len(), 1);
3898 assert!(matches!(
3899 candidates[0].source,
3900 UpdateReleaseSource::Cnb { .. }
3901 ));
3902 }
3903
3904 #[test]
3905 fn beta_tags_use_the_same_ordered_sources_as_stable_tags() {
3906 let candidates = linux_x64_candidates("v0.9.0-beta.2");
3907
3908 assert_eq!(candidates[0].source, UpdateReleaseSource::GitHub);
3909 assert_eq!(
3910 candidates[1].source,
3911 UpdateReleaseSource::Cnb {
3912 base_url: cnb_release_base_url("v0.9.0-beta.2"),
3913 },
3914 "the beta tag must be carried into the CNB URL verbatim"
3915 );
3916 }
3917
3918 #[test]
3919 fn explicit_overrides_take_precedence_over_probing() {
3920 {
3921 let _env = UpdateEnvGuard::clear();
3922 set_update_env(
3923 codewhale_release::RELEASE_BASE_URL_ENV,
3924 "https://mirror.example/assets",
3925 );
3926 set_update_env(codewhale_release::UPDATE_VERSION_ENV, "9.9.9");
3927
3928 let fetched = fetch_latest_release(ReleaseChannel::Stable, None)
3929 .expect("a pinned mirror resolves without a network");
3930
3931 assert_eq!(
3932 fetched.source,
3933 UpdateReleaseSource::Mirror {
3934 base_url: "https://mirror.example/assets".to_string(),
3935 }
3936 );
3937 assert!(fetched.source.is_pinned_mirror());
3938 assert!(
3939 candidates_for(&fetched, "linux", "x86_64").is_none(),
3940 "an explicit base URL must never fall back to CNB"
3941 );
3942 assert_eq!(
3943 describe_release_source_for_check(&fetched, "codewhale-linux-x64", None),
3944 "release mirror (https://mirror.example/assets)"
3945 );
3946 }
3947
3948 {
3949 let _env = UpdateEnvGuard::clear();
3950 set_update_env(codewhale_release::CNB_MIRROR_ENV, "1");
3951 set_update_env(codewhale_release::UPDATE_VERSION_ENV, "9.9.9");
3952
3953 let fetched = fetch_latest_release(ReleaseChannel::Stable, None)
3954 .expect("the CNB override resolves without a network");
3955
3956 assert_eq!(
3957 fetched.source,
3958 UpdateReleaseSource::Cnb {
3959 base_url: cnb_release_base_url("9.9.9"),
3960 },
3961 "an explicit CNB request must be reported as CNB, not as a generic mirror"
3962 );
3963 assert!(
3964 candidates_for(&fetched, "linux", "x86_64").is_none(),
3965 "an explicit CNB request must not fall back to GitHub"
3966 );
3967 }
3968
3969 {
3970 let _env = UpdateEnvGuard::clear();
3971 set_update_env(codewhale_release::CNB_MIRROR_ENV, "1");
3972 set_update_env(
3973 codewhale_release::RELEASE_BASE_URL_ENV,
3974 "https://mirror.example/assets",
3975 );
3976
3977 let fetched = fetch_latest_release(ReleaseChannel::Stable, None)
3978 .expect("a pinned mirror resolves without a network");
3979
3980 assert_eq!(
3981 fetched.source,
3982 UpdateReleaseSource::Mirror {
3983 base_url: "https://mirror.example/assets".to_string(),
3984 },
3985 "an explicit base URL outranks the CNB flag"
3986 );
3987 }
3988 }
3989
3990 #[test]
3991 fn a_locked_source_serves_both_the_manifest_and_the_binary() {
3992 const BINARY: &[u8] = b"\x7fELF codewhale linux x64 payload";
3993 let manifest = format!("{} codewhale-linux-x64\n", sha256_hex(BINARY));
3994 let (url, request_rx, handle) = serve_http_owned_responses(vec![
3995 ("200 OK", "text/plain", manifest.into_bytes()),
3996 ("200 OK", "application/octet-stream", BINARY.to_vec()),
3997 ]);
3998 let origin = url.trim_end_matches("/release").to_string();
3999 let candidate = ReleaseSourceCandidate {
4000 source: UpdateReleaseSource::Cnb {
4001 base_url: origin.clone(),
4002 },
4003 manifest_url: mirror_asset_url(&origin, CHECKSUM_MANIFEST_ASSET),
4004 binary_name: "codewhale-linux-x64".to_string(),
4005 binary_url: mirror_asset_url(&origin, "codewhale-linux-x64"),
4006 };
4007
4008 let plan = select_release_source(vec![candidate], manifest_probe_fetcher(None))
4009 .expect("the only reachable source must win");
4010 let bytes = download_url(&plan.binary_url, None).expect("binary download");
4011 verify_downloaded_asset(&plan, &bytes)
4012 .expect("bytes from the locked source must match its own manifest");
4013
4014 assert_eq!(bytes, BINARY);
4015 let manifest_request = request_rx.recv().expect("manifest request");
4016 let binary_request = request_rx.recv().expect("binary request");
4017 assert!(
4018 manifest_request.starts_with("GET /codewhale-artifacts-sha256.txt "),
4019 "got {manifest_request:?}"
4020 );
4021 assert!(
4022 binary_request.starts_with("GET /codewhale-linux-x64 "),
4023 "got {binary_request:?}"
4024 );
4025 handle.join().expect("test server thread");
4026 }
4027
4028 #[test]
4029 fn a_checksum_mismatch_fails_closed_and_names_the_source() {
4030 let mut plan = DownloadPlan {
4031 source: UpdateReleaseSource::Cnb {
4032 base_url: cnb_release_base_url("v9.9.9"),
4033 },
4034 binary_name: "codewhale-linux-x64".to_string(),
4035 binary_url: "https://cnb.example/codewhale-linux-x64".to_string(),
4036 checksums: HashMap::from([("codewhale-linux-x64".to_string(), "a".repeat(64))]),
4037 };
4038
4039 let err = verify_downloaded_asset(&plan, b"tampered bytes")
4040 .expect_err("a mismatch must never install");
4041 let message = format!("{err:#}");
4042 assert!(message.contains("SHA256 mismatch"), "{message}");
4043 assert!(message.contains("CNB mirror"), "{message}");
4044
4045 plan.checksums = HashMap::from([("codew-linux-x64".to_string(), "a".repeat(64))]);
4046 let err = verify_downloaded_asset(&plan, b"bytes")
4047 .expect_err("an uncovered asset must never install");
4048 let message = format!("{err:#}");
4049 assert!(
4050 message.contains("is missing codewhale-linux-x64"),
4051 "{message}"
4052 );
4053 }
4054
4055 #[test]
4056 fn release_sources_describe_themselves_for_status_and_receipts() {
4057 assert_eq!(UpdateReleaseSource::GitHub.describe(), "GitHub Releases");
4058 assert!(!UpdateReleaseSource::GitHub.is_pinned_mirror());
4059
4060 let cnb = UpdateReleaseSource::Cnb {
4061 base_url: cnb_release_base_url("v9.9.9"),
4062 };
4063 assert_eq!(
4064 cnb.describe(),
4065 "CNB mirror (https://cnb.cool/codewhale.net/codewhale/-/releases/download/v9.9.9)"
4066 );
4067 assert!(cnb.is_pinned_mirror());
4068
4069 let mirror = UpdateReleaseSource::Mirror {
4070 base_url: "https://mirror.example/assets".to_string(),
4071 };
4072 assert_eq!(
4073 mirror.describe(),
4074 "release mirror (https://mirror.example/assets)"
4075 );
4076 assert!(mirror.is_pinned_mirror());
4077 }
4078
4079 #[test]
4080 fn validate_and_build_proxy_accepts_supported_proxy_urls() {
4081 validate_and_build_proxy("http://localhost:7897").expect("http proxy");
4082 validate_and_build_proxy("https://proxy.example.com:8080").expect("https proxy");
4083 validate_and_build_proxy("socks5://127.0.0.1:1080").expect("socks proxy");
4084 }
4085
4086 #[test]
4087 fn validate_and_build_proxy_rejects_malformed_urls() {
4088 let err = validate_and_build_proxy("not a valid url").expect_err("malformed URL");
4089 assert!(err.to_string().contains("invalid proxy URL"));
4090 }
4091
4092 #[test]
4093 fn fetch_latest_release_from_url_reads_mocked_release_json() {
4094 let body = br#"{
4095 "tag_name": "v9.9.9",
4096 "assets": [
4097 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" },
4098 { "name": "codewhale-artifacts-sha256.txt", "browser_download_url": "http://example.invalid/codewhale-artifacts-sha256.txt" }
4099 ]
4100 }"#;
4101 let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body);
4102 let release = fetch_latest_release_from_url(&url, None).expect("release JSON should parse");
4103
4104 assert_eq!(release.tag_name, "v9.9.9");
4105 assert_eq!(release.assets.len(), 2);
4106
4107 let request = request_rx.recv().expect("captured request");
4108 let request_lower = request.to_ascii_lowercase();
4109 assert!(request.starts_with("GET /release "), "got {request:?}");
4110 assert!(
4111 request_lower.contains("accept: application/vnd.github+json"),
4112 "got {request:?}"
4113 );
4114 assert!(
4115 request_lower.contains("user-agent: codewhale-updater"),
4116 "got {request:?}"
4117 );
4118 handle.join().expect("test server thread");
4119 }
4120
4121 #[test]
4122 fn fetch_latest_release_from_url_retries_transient_gateway_error() {
4123 let body = br#"{
4124 "tag_name": "v9.9.9",
4125 "assets": [
4126 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" }
4127 ]
4128 }"#;
4129 let (url, request_rx, handle) = serve_http_responses(vec![
4130 ("504 Gateway Timeout", "text/plain", b"gateway timeout"),
4131 ("200 OK", "application/json", body),
4132 ]);
4133 let release = fetch_latest_release_from_url(&url, None)
4134 .expect("release JSON should parse after retry");
4135
4136 assert_eq!(release.tag_name, "v9.9.9");
4137 let first = request_rx.recv().expect("first request");
4138 let second = request_rx.recv().expect("second request");
4139 assert!(first.starts_with("GET /release "), "got {first:?}");
4140 assert!(second.starts_with("GET /release "), "got {second:?}");
4141 handle.join().expect("test server thread");
4142 }
4143
4144 #[test]
4145 fn fetch_latest_release_from_url_reports_http_errors() {
4146 let (url, _request_rx, handle) = serve_http_responses(vec![
4147 ("500 Internal Server Error", "text/plain", b"server broke"),
4148 ("500 Internal Server Error", "text/plain", b"server broke"),
4149 ("500 Internal Server Error", "text/plain", b"server broke"),
4150 ]);
4151 let err = fetch_latest_release_from_url(&url, None).expect_err("HTTP 500 should fail");
4152
4153 assert!(
4154 err.to_string().contains("HTTP 500"),
4155 "unexpected error: {err:#}"
4156 );
4157 handle.join().expect("test server thread");
4158 }
4159
4160 #[test]
4161 fn fetch_latest_beta_release_from_url_selects_first_beta_release() {
4162 let body = br#"[
4163 { "tag_name": "v0.9.0", "prerelease": false, "assets": [] },
4164 { "tag_name": "v0.9.0-rc.1", "prerelease": true, "assets": [] },
4165 { "tag_name": "v0.9.0-beta.2", "prerelease": true, "assets": [
4166 { "name": "codewhale-linux-x64", "browser_download_url": "http://example.invalid/codewhale-linux-x64" }
4167 ] },
4168 { "tag_name": "v0.9.0-beta.1", "prerelease": true, "assets": [] }
4169 ]"#;
4170 let (url, request_rx, handle) = serve_http_once("200 OK", "application/json", body);
4171 let release =
4172 fetch_latest_beta_release_from_url(&url, None).expect("beta release JSON should parse");
4173
4174 assert_eq!(release.tag_name, "v0.9.0-beta.2");
4175 assert!(release.prerelease);
4176
4177 let request = request_rx.recv().expect("captured request");
4178 let request_lower = request.to_ascii_lowercase();
4179 assert!(request.starts_with("GET /release "), "got {request:?}");
4180 assert!(
4181 request_lower.contains("accept: application/vnd.github+json"),
4182 "got {request:?}"
4183 );
4184 handle.join().expect("test server thread");
4185 }
4186
4187 #[test]
4188 fn fetch_latest_beta_release_from_url_reports_missing_beta() {
4189 let body = br#"[
4190 { "tag_name": "v0.9.0", "prerelease": false, "assets": [] }
4191 ]"#;
4192 let (url, _request_rx, handle) = serve_http_once("200 OK", "application/json", body);
4193 let err =
4194 fetch_latest_beta_release_from_url(&url, None).expect_err("missing beta should fail");
4195
4196 assert!(
4197 err.to_string().contains("no beta release found"),
4198 "unexpected error: {err:#}"
4199 );
4200 handle.join().expect("test server thread");
4201 }
4202
4203 #[test]
4204 fn download_url_retries_transient_gateway_error() {
4205 let (url, request_rx, handle) = serve_http_responses(vec![
4206 ("503 Service Unavailable", "text/plain", b"try again"),
4207 ("200 OK", "application/octet-stream", b"\0binary bytes"),
4208 ]);
4209 let bytes = download_url(&url, None).expect("binary download should retry and succeed");
4210
4211 assert_eq!(bytes, b"\0binary bytes");
4212 let first = request_rx.recv().expect("first request");
4213 let second = request_rx.recv().expect("second request");
4214 assert!(first.starts_with("GET /release "), "got {first:?}");
4215 assert!(second.starts_with("GET /release "), "got {second:?}");
4216 handle.join().expect("test server thread");
4217 }
4218
4219 #[test]
4220 fn download_url_reads_binary_body_with_updater_user_agent() {
4221 let (url, request_rx, handle) =
4222 serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes");
4223 let bytes = download_url(&url, None).expect("binary download should succeed");
4224
4225 assert_eq!(bytes, b"\0binary bytes");
4226
4227 let request = request_rx.recv().expect("captured request");
4228 let request_lower = request.to_ascii_lowercase();
4229 assert!(request.starts_with("GET /release "), "got {request:?}");
4230 assert!(
4231 request_lower.contains("user-agent: codewhale-updater"),
4232 "got {request:?}"
4233 );
4234 handle.join().expect("test server thread");
4235 }
4236 }
4237
4237 lines RUST