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