1use anyhow::{Context, Result};
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14use tracing::{debug, warn};
15
16const CHECK_INTERVAL_SECS: u64 = 3600;
18
19const HTTP_TIMEOUT_SECS: u64 = 5;
21
22const GITHUB_REPO: &str = "Dicklesworthstone/coding_agent_session_search";
24#[cfg(any(test, target_os = "macos", target_os = "linux"))]
25const UNIX_INSTALL_ASSET: &str = "install.sh";
26#[cfg(any(test, target_os = "windows"))]
27const WINDOWS_INSTALL_ASSET: &str = "install.ps1";
28const CHECKSUMS_ASSET: &str = "SHA256SUMS.txt";
29const CHECKSUMS_ASSET_ALT: &str = "SHA256SUMS";
30#[cfg(any(test, target_os = "macos", target_os = "linux"))]
35const UNIX_INSTALL_CHECKSUM_ASSET: &str = "install.sh.sha256";
36#[cfg(any(test, target_os = "windows"))]
39const WINDOWS_INSTALL_CHECKSUM_ASSET: &str = "install.ps1.sha256";
40
41fn updates_disabled() -> bool {
42 dotenvy::var("CASS_SKIP_UPDATE").is_ok()
43 || dotenvy::var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT").is_ok()
44 || dotenvy::var("TUI_HEADLESS").is_ok()
45 || dotenvy::var("CI").is_ok()
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct UpdateState {
51 pub last_check_ts: i64,
53 pub skipped_version: Option<String>,
55}
56
57impl UpdateState {
58 pub fn load() -> Self {
60 let path = state_path();
61 match std::fs::read_to_string(&path) {
62 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
63 Err(_) => {
64 let legacy = legacy_state_path();
65 if legacy != path
66 && let Ok(content) = std::fs::read_to_string(&legacy)
67 {
68 return serde_json::from_str(&content).unwrap_or_default();
69 }
70 Self::default()
71 }
72 }
73 }
74
75 pub async fn load_async() -> Self {
77 let path = state_path();
78 match asupersync::fs::read_to_string(&path).await {
79 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
80 Err(_) => {
81 let legacy = legacy_state_path();
82 if legacy != path
83 && let Ok(content) = asupersync::fs::read_to_string(&legacy).await
84 {
85 return serde_json::from_str(&content).unwrap_or_default();
86 }
87 Self::default()
88 }
89 }
90 }
91
92 pub fn save(&self) -> Result<()> {
94 let path = state_path();
95 if let Some(parent) = path.parent() {
96 std::fs::create_dir_all(parent)
97 .with_context(|| format!("creating update state directory {}", parent.display()))?;
98 }
99 let json = serde_json::to_string_pretty(self)?;
100 let temp_path = write_update_state_temp_file(&path, json.as_bytes())
101 .with_context(|| format!("writing temporary update state for {}", path.display()))?;
102 replace_update_state_file_from_temp(&temp_path, &path)
103 .with_context(|| format!("replacing {}", path.display()))?;
104 Ok(())
105 }
106
107 pub async fn save_async(&self) -> Result<()> {
109 let path = state_path();
110 if let Some(parent) = path.parent() {
111 asupersync::fs::create_dir_all(parent)
112 .await
113 .with_context(|| format!("creating update state directory {}", parent.display()))?;
114 }
115 let json = serde_json::to_string_pretty(self).context("serializing update state")?;
116 let temp_path = write_update_state_temp_file_async(&path, json.as_bytes())
117 .await
118 .with_context(|| format!("writing temporary update state for {}", path.display()))?;
119 replace_update_state_file_from_temp(&temp_path, &path)
120 .with_context(|| format!("replacing {}", path.display()))?;
121 Ok(())
122 }
123
124 pub fn should_check(&self) -> bool {
126 let now = now_unix();
127 if self.last_check_ts <= 0 || self.last_check_ts > now {
128 return true;
129 }
130 now.saturating_sub(self.last_check_ts) >= CHECK_INTERVAL_SECS as i64
131 }
132
133 pub fn mark_checked(&mut self) {
135 self.last_check_ts = now_unix();
136 }
137
138 pub fn skip_version(&mut self, version: &str) {
140 self.skipped_version = Some(version.to_string());
141 }
142
143 pub fn is_skipped(&self, version: &str) -> bool {
145 self.skipped_version.as_deref() == Some(version)
146 }
147
148 pub fn clear_skip(&mut self) {
150 self.skipped_version = None;
151 }
152}
153
154#[derive(Debug, Clone)]
156pub struct UpdateInfo {
157 pub latest_version: String,
159 pub tag_name: String,
161 pub current_version: String,
163 pub release_url: String,
165 pub is_newer: bool,
167 pub is_skipped: bool,
169}
170
171impl UpdateInfo {
172 pub fn should_show(&self) -> bool {
174 self.is_newer && !self.is_skipped
175 }
176}
177
178#[derive(Debug, Deserialize)]
180struct GitHubRelease {
181 tag_name: String,
182 html_url: String,
183}
184
185pub async fn check_for_updates(current_version: &str) -> Option<UpdateInfo> {
192 check_for_updates_async_impl(current_version, false).await
193}
194
195async fn check_for_updates_async_impl(current_version: &str, force: bool) -> Option<UpdateInfo> {
196 if updates_disabled() {
198 return None;
199 }
200
201 let mut state = UpdateState::load_async().await;
202
203 if !force && !state.should_check() {
205 debug!("update check: skipping, checked recently");
206 return None;
207 }
208
209 let release = match fetch_latest_release().await {
210 Ok(r) => r,
211 Err(e) => {
212 debug!("update check: fetch failed (offline?): {e}");
213 return None;
214 }
215 };
216
217 let info = build_update_info(current_version, release, &state)?;
218
219 state.mark_checked();
222 if let Err(e) = state.save_async().await {
223 warn!("update check: failed to save state: {e}");
224 }
225
226 Some(info)
227}
228
229pub async fn force_check(current_version: &str) -> Option<UpdateInfo> {
231 check_for_updates_async_impl(current_version, true).await
232}
233
234pub fn skip_version(version: &str) -> Result<()> {
236 let mut state = UpdateState::load();
237 state.skip_version(version);
238 state.save()
239}
240
241pub fn open_in_browser(url: &str) -> std::io::Result<()> {
243 validate_browser_url(url)?;
244
245 #[cfg(target_os = "windows")]
246 {
247 std::process::Command::new("rundll32")
248 .args(["url.dll,FileProtocolHandler", url])
249 .spawn()?;
250 }
251 #[cfg(target_os = "macos")]
252 {
253 std::process::Command::new("open").arg(url).spawn()?;
254 }
255 #[cfg(target_os = "linux")]
256 {
257 std::process::Command::new("xdg-open").arg(url).spawn()?;
258 }
259 Ok(())
260}
261
262fn validate_browser_url(url: &str) -> std::io::Result<()> {
263 if is_browser_url(url) {
264 Ok(())
265 } else {
266 Err(std::io::Error::new(
267 std::io::ErrorKind::InvalidInput,
268 "release notes URL must be an absolute http(s) URL",
269 ))
270 }
271}
272
273fn is_browser_url(url: &str) -> bool {
274 let Ok(parsed) = url::Url::parse(url) else {
275 return false;
276 };
277 if url_has_userinfo(&parsed) {
278 return false;
279 }
280 matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some()
281}
282
283fn is_trusted_release_notes_url(url: &str, tag_name: &str) -> bool {
284 let Ok(parsed) = url::Url::parse(url) else {
285 return false;
286 };
287 if parsed.scheme() != "https"
288 || parsed.host_str() != Some("github.com")
289 || url_has_userinfo(&parsed)
290 || parsed.query().is_some()
291 || parsed.fragment().is_some()
292 {
293 return false;
294 }
295
296 let Some((expected_owner, expected_repo)) = GITHUB_REPO.split_once('/') else {
297 return false;
298 };
299 let Some(mut path_segments) = parsed.path_segments() else {
300 return false;
301 };
302 let Some(owner) = path_segments.next() else {
303 return false;
304 };
305 let Some(repo) = path_segments.next() else {
306 return false;
307 };
308 let Some(section) = path_segments.next() else {
309 return false;
310 };
311 let Some(kind) = path_segments.next() else {
312 return false;
313 };
314 let tag_path = path_segments.collect::<Vec<_>>().join("/");
315 if tag_path.is_empty() {
316 return false;
317 }
318
319 let tag_matches = release_tag_path_matches(&tag_path, tag_name);
320
321 owner.eq_ignore_ascii_case(expected_owner)
322 && repo.eq_ignore_ascii_case(expected_repo)
323 && section == "releases"
324 && kind == "tag"
325 && tag_matches
326}
327
328fn url_has_userinfo(url: &url::Url) -> bool {
329 !url.username().is_empty() || url.password().is_some()
330}
331
332fn release_tag_path_matches(tag_path: &str, tag_name: &str) -> bool {
333 if tag_path == tag_name {
334 return true;
335 }
336 urlencoding::decode(tag_path)
337 .map(|decoded| decoded.as_ref() == tag_name)
338 .unwrap_or(false)
339}
340
341fn release_asset_url(version: &str, asset: &str) -> String {
342 format!("https://github.com/{GITHUB_REPO}/releases/download/{version}/{asset}")
343}
344
345fn parse_update_tag(tag: &str) -> Option<(&str, Version)> {
346 if tag.trim() != tag {
347 return None;
348 }
349
350 let version = tag.strip_prefix('v').unwrap_or(tag);
351 let parsed = Version::parse(version).ok()?;
352 Some((version, parsed))
353}
354
355fn is_valid_update_tag(tag: &str) -> bool {
356 parse_update_tag(tag).is_some()
357}
358
359#[cfg(any(test, target_os = "macos", target_os = "linux"))]
360fn unix_self_update_script() -> &'static str {
361 r#"
362set -euo pipefail
363
364tmp="$(mktemp -d "${TMPDIR:-/tmp}/cass-self-update.XXXXXX")"
365cleanup() {
366 rm -r "$tmp" 2>/dev/null || true
367}
368trap cleanup EXIT
369
370script="$tmp/install.sh"
371sums="$tmp/SHA256SUMS.txt"
372curl -fsSL "$1" -o "$script"
373expected=""
374for checksums_url in "$2" "$4" "$5"; do
375 [ -n "$checksums_url" ] || continue
376 if ! curl -fsSL "$checksums_url" -o "$sums"; then
377 continue
378 fi
379 candidate="$(awk '$2 == "install.sh" { print $1; exit }' "$sums")"
380 if printf '%s' "$candidate" | grep -Eq '^[0-9a-fA-F]{64}$'; then
381 expected="$candidate"
382 break
383 fi
384done
385if ! printf '%s' "$expected" | grep -Eq '^[0-9a-fA-F]{64}$'; then
386 echo "install.sh checksum missing from release checksum manifests" >&2
387 exit 1
388fi
389expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')"
390
391if command -v sha256sum >/dev/null 2>&1; then
392 printf '%s %s\n' "$expected_lc" "$script" | sha256sum -c -
393elif command -v shasum >/dev/null 2>&1; then
394 actual="$(shasum -a 256 "$script" | awk '{ print $1 }' | tr '[:upper:]' '[:lower:]')"
395 if [ "$actual" != "$expected_lc" ]; then
396 echo "install.sh checksum mismatch" >&2
397 exit 1
398 fi
399elif command -v openssl >/dev/null 2>&1; then
400 actual="$(openssl dgst -sha256 "$script" | awk '{ print $NF }' | tr '[:upper:]' '[:lower:]')"
401 if [ "$actual" != "$expected_lc" ]; then
402 echo "install.sh checksum mismatch" >&2
403 exit 1
404 fi
405else
406 echo "No SHA-256 verification tool found" >&2
407 exit 1
408fi
409
410exec bash "$script" --easy-mode --verify --version "$3"
411"#
412}
413
414#[cfg(any(test, target_os = "windows"))]
415fn windows_self_update_script() -> &'static str {
416 r#"
417$InstallUrl = $args[0]
418$ChecksumsUrl = $args[1]
419$Version = $args[2]
420$Temp = Join-Path ([IO.Path]::GetTempPath()) ("cass-self-update-" + [guid]::NewGuid().ToString("N"))
421New-Item -ItemType Directory -Path $Temp -Force | Out-Null
422try {
423 $Script = Join-Path $Temp "install.ps1"
424 $Sums = Join-Path $Temp "SHA256SUMS.txt"
425 Invoke-WebRequest -Uri $InstallUrl -OutFile $Script -UseBasicParsing
426
427 $Expected = $null
428 foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3], $args[4])) {
429 if (-not $ChecksumsCandidateUrl) {
430 continue
431 }
432 try {
433 Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums -UseBasicParsing
434 } catch {
435 continue
436 }
437
438 foreach ($Line in Get-Content -LiteralPath $Sums) {
439 $Parts = $Line.Trim() -split '\s+', 2
440 if ($Parts.Count -ge 2 -and $Parts[1] -eq "install.ps1" -and $Parts[0] -match '^[0-9a-fA-F]{64}$') {
441 $Expected = $Parts[0].ToLowerInvariant()
442 break
443 }
444 }
445 if ($Expected) {
446 break
447 }
448 }
449 if (-not $Expected) {
450 Write-Error "install.ps1 checksum missing from release checksum manifests"
451 exit 1
452 }
453
454 $Actual = (Get-FileHash -LiteralPath $Script -Algorithm SHA256).Hash.ToLowerInvariant()
455 if ($Actual -ne $Expected) {
456 Write-Error "install.ps1 checksum mismatch"
457 exit 1
458 }
459
460 & $Script -EasyMode -Verify -Version $Version
461 exit $LASTEXITCODE
462} finally {
463 Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue
464}
465"#
466}
467
468pub fn run_self_update(version: &str) -> ! {
472 if !is_valid_update_tag(version) {
475 eprintln!("Invalid version string: {}", version);
476 std::process::exit(1);
477 }
478
479 #[cfg(any(target_os = "macos", target_os = "linux"))]
480 {
481 use std::os::unix::process::CommandExt;
482 let install_url = release_asset_url(version, UNIX_INSTALL_ASSET);
483 let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
484 let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
485 let install_checksum_url = release_asset_url(version, UNIX_INSTALL_CHECKSUM_ASSET);
486 let err = std::process::Command::new("bash")
488 .args([
489 "-c",
490 unix_self_update_script(),
491 "cass-updater",
492 &install_url,
493 &checksums_url,
494 version,
495 &checksums_alt_url,
496 &install_checksum_url,
497 ])
498 .exec();
499 eprintln!("Failed to run installer: {}", err);
501 std::process::exit(1);
502 }
503
504 #[cfg(target_os = "windows")]
505 {
506 let install_url = release_asset_url(version, WINDOWS_INSTALL_ASSET);
507 let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
508 let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
509 let install_checksum_url = release_asset_url(version, WINDOWS_INSTALL_CHECKSUM_ASSET);
510 let status = std::process::Command::new("powershell")
512 .args([
513 "-ExecutionPolicy",
514 "Bypass",
515 "-NoProfile",
516 "-Command",
517 windows_self_update_script(),
518 &install_url,
519 &checksums_url,
520 version,
521 &checksums_alt_url,
522 &install_checksum_url,
523 ])
524 .status();
525 match status {
526 Ok(s) => std::process::exit(s.code().unwrap_or(0)),
527 Err(e) => {
528 eprintln!("Failed to run installer: {}", e);
529 std::process::exit(1);
530 }
531 }
532 }
533}
534
535fn release_api_base_url() -> String {
550 let default = || format!("https://api.github.com/repos/{GITHUB_REPO}");
551 let Ok(override_url) = dotenvy::var("CASS_UPDATE_API_BASE_URL") else {
552 return default();
553 };
554 if is_allowed_update_api_url(&override_url) {
555 override_url
556 } else {
557 eprintln!(
558 "warning: CASS_UPDATE_API_BASE_URL={override_url:?} ignored \
559 (only GitHub HTTPS URLs or http://localhost/127.0.0.1 test endpoints allowed). \
560 Falling back to the default GitHub release API."
561 );
562 default()
563 }
564}
565
566fn is_allowed_update_api_url(url: &str) -> bool {
571 let Ok(parsed) = url::Url::parse(url) else {
572 return false;
573 };
574 let Some(host) = parsed.host_str() else {
575 return false;
576 };
577 if url_has_userinfo(&parsed) {
578 return false;
579 }
580
581 match parsed.scheme() {
582 "https" => matches!(host, "api.github.com" | "github.com"),
583 "http" => matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]"),
584 _ => false,
585 }
586}
587
588fn state_path() -> PathBuf {
590 crate::default_data_dir().join("update_state.json")
591}
592
593fn legacy_state_path() -> PathBuf {
594 directories::ProjectDirs::from("com", "coding-agent-search", "coding-agent-search").map_or_else(
595 || PathBuf::from("update_state.json"),
596 |dirs| dirs.data_dir().join("update_state.json"),
597 )
598}
599
600fn write_update_state_temp_file(path: &Path, contents: &[u8]) -> std::io::Result<PathBuf> {
601 for _ in 0..100 {
602 let temp_path = unique_update_state_temp_path(path);
603 match write_update_state_temp_file_at(&temp_path, contents) {
604 Ok(()) => return Ok(temp_path),
605 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
606 Err(err) => return Err(err),
607 }
608 }
609
610 Err(std::io::Error::new(
611 std::io::ErrorKind::AlreadyExists,
612 format!(
613 "failed to allocate unique update state temp path for {}",
614 path.display()
615 ),
616 ))
617}
618
619fn write_update_state_temp_file_at(path: &Path, contents: &[u8]) -> std::io::Result<()> {
620 use std::io::Write;
621
622 let mut file = std::fs::OpenOptions::new()
623 .write(true)
624 .create_new(true)
625 .open(path)?;
626 file.write_all(contents)?;
627 file.sync_all()
628}
629
630async fn write_update_state_temp_file_async(
631 path: &Path,
632 contents: &[u8],
633) -> std::io::Result<PathBuf> {
634 for _ in 0..100 {
635 let temp_path = unique_update_state_temp_path(path);
636 match write_update_state_temp_file_at_async(&temp_path, contents).await {
637 Ok(()) => return Ok(temp_path),
638 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
639 Err(err) => return Err(err),
640 }
641 }
642
643 Err(std::io::Error::new(
644 std::io::ErrorKind::AlreadyExists,
645 format!(
646 "failed to allocate unique update state temp path for {}",
647 path.display()
648 ),
649 ))
650}
651
652async fn write_update_state_temp_file_at_async(
653 path: &Path,
654 contents: &[u8],
655) -> std::io::Result<()> {
656 use asupersync::io::AsyncWriteExt;
657
658 let mut file = asupersync::fs::OpenOptions::new()
659 .write(true)
660 .create_new(true)
661 .open(path)
662 .await?;
663 file.write_all(contents).await?;
664 file.sync_all().await
665}
666
667fn replace_update_state_file_from_temp(temp_path: &Path, final_path: &Path) -> std::io::Result<()> {
668 #[cfg(windows)]
669 {
670 match std::fs::rename(temp_path, final_path) {
671 Ok(()) => sync_parent_directory(final_path),
672 Err(first_err)
673 if update_state_path_entry_exists(final_path)?
674 && matches!(
675 first_err.kind(),
676 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
677 ) =>
678 {
679 let backup_path = unique_update_state_backup_path(final_path);
680 std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
681 std::io::Error::other(format!(
682 "failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
683 backup_path.display(),
684 final_path.display(),
685 first_err,
686 backup_err
687 ))
688 })?;
689 match std::fs::rename(temp_path, final_path) {
690 Ok(()) => sync_parent_directory(final_path),
691 Err(second_err) => match std::fs::rename(&backup_path, final_path) {
692 Ok(()) => {
693 sync_parent_directory(final_path)?;
694 Err(std::io::Error::other(format!(
695 "failed replacing {} with {}: first error: {}; second error: {}; restored original file; temp file retained at {}",
696 final_path.display(),
697 temp_path.display(),
698 first_err,
699 second_err,
700 temp_path.display()
701 )))
702 }
703 Err(restore_err) => Err(std::io::Error::other(format!(
704 "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
705 final_path.display(),
706 temp_path.display(),
707 first_err,
708 second_err,
709 restore_err,
710 temp_path.display()
711 ))),
712 },
713 }
714 }
715 Err(err) => Err(err),
716 }
717 }
718
719 #[cfg(not(windows))]
720 {
721 std::fs::rename(temp_path, final_path)?;
722 sync_parent_directory(final_path)
723 }
724}
725
726#[cfg(any(windows, test))]
727fn update_state_path_entry_exists(path: &Path) -> std::io::Result<bool> {
728 match std::fs::symlink_metadata(path) {
729 Ok(_) => Ok(true),
730 Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => Ok(false),
731 Err(err) => Err(std::io::Error::new(
732 err.kind(),
733 format!(
734 "failed inspecting update state replacement target {}: {err}",
735 path.display()
736 ),
737 )),
738 }
739}
740
741#[cfg(not(windows))]
742fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
743 let Some(parent) = path.parent() else {
744 return Ok(());
745 };
746 std::fs::File::open(parent)?.sync_all()
747}
748
749#[cfg(windows)]
750fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
751 Ok(())
752}
753
754fn unique_update_state_temp_path(path: &Path) -> PathBuf {
755 unique_update_state_sidecar_path(path, "tmp")
756}
757
758#[cfg(windows)]
759fn unique_update_state_backup_path(path: &Path) -> PathBuf {
760 unique_update_state_sidecar_path(path, "bak")
761}
762
763fn unique_update_state_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
764 static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
765
766 let timestamp = SystemTime::now()
767 .duration_since(UNIX_EPOCH)
768 .unwrap_or_default()
769 .as_nanos();
770 let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
771 let file_name = path
772 .file_name()
773 .and_then(|name| name.to_str())
774 .unwrap_or("update_state.json");
775
776 path.with_file_name(format!(
777 ".{file_name}.{suffix}.{}.{}.{}",
778 std::process::id(),
779 timestamp,
780 nonce
781 ))
782}
783
784fn now_unix() -> i64 {
786 i64::try_from(
787 SystemTime::now()
788 .duration_since(UNIX_EPOCH)
789 .unwrap_or_default()
790 .as_secs(),
791 )
792 .unwrap_or(i64::MAX)
793}
794
795pub fn check_for_updates_sync(current_version: &str) -> Option<UpdateInfo> {
802 if updates_disabled() {
803 return None;
804 }
805
806 let mut state = UpdateState::load();
807
808 if !state.should_check() {
810 debug!("update check: skipping, checked recently");
811 return None;
812 }
813
814 let release = match fetch_latest_release_blocking() {
816 Ok(r) => r,
817 Err(e) => {
818 debug!("update check: fetch failed (offline?): {e}");
819 return None;
820 }
821 };
822
823 let info = build_update_info(current_version, release, &state)?;
824
825 state.mark_checked();
828 if let Err(e) = state.save() {
829 warn!("update check: failed to save state: {e}");
830 }
831
832 Some(info)
833}
834
835fn build_update_info(
836 current_version: &str,
837 release: GitHubRelease,
838 state: &UpdateState,
839) -> Option<UpdateInfo> {
840 let GitHubRelease { tag_name, html_url } = release;
841 let (latest_version, latest) = match parse_update_tag(&tag_name) {
842 Some((version, parsed)) => (version.to_string(), parsed),
843 None => {
844 debug!("update check: invalid version tag '{}'", tag_name);
845 return None;
846 }
847 };
848 if !is_trusted_release_notes_url(&html_url, &tag_name) {
849 debug!("update check: untrusted release notes URL '{}'", html_url);
850 return None;
851 }
852
853 let current = match Version::parse(current_version) {
854 Ok(v) => v,
855 Err(e) => {
856 debug!("update check: invalid current version '{current_version}': {e}");
857 return None;
858 }
859 };
860 let is_skipped = state.is_skipped(&latest_version);
861
862 Some(UpdateInfo {
863 latest_version,
864 tag_name,
865 current_version: current_version.to_string(),
866 release_url: html_url,
867 is_newer: latest > current,
868 is_skipped,
869 })
870}
871
872async fn fetch_latest_release() -> Result<GitHubRelease> {
874 asupersync::runtime::spawn_blocking(fetch_latest_release_blocking).await
875}
876
877fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
879 let url = format!("{}/releases/latest", release_api_base_url());
880 let client = reqwest::blocking::Client::builder()
881 .user_agent(concat!("cass/", env!("CARGO_PKG_VERSION")))
882 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
883 .build()
884 .context("building update-check HTTP client")?;
885
886 let response = client
887 .get(&url)
888 .header(reqwest::header::ACCEPT, "application/vnd.github.v3+json")
889 .send()
890 .with_context(|| format!("fetching release metadata from {url}"))?;
891 let status = response.status();
892 if !status.is_success() {
893 anyhow::bail!("GitHub API returned {}", status.as_u16());
894 }
895
896 response
897 .json::<GitHubRelease>()
898 .context("parsing release JSON")
899}
900
901pub fn spawn_update_check(
904 current_version: String,
905) -> std::sync::mpsc::Receiver<Option<UpdateInfo>> {
906 let (tx, rx) = std::sync::mpsc::channel();
907 if updates_disabled() {
908 let _ = tx.send(None);
909 return rx;
910 }
911 std::thread::spawn(move || {
912 let result = check_for_updates_sync(¤t_version);
913 let _ = tx.send(result);
914 });
915 rx
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use serial_test::serial;
922
923 #[test]
924 fn test_release_asset_url_uses_immutable_release_downloads() {
925 assert_eq!(
926 release_asset_url("v1.2.3", UNIX_INSTALL_ASSET),
927 format!(
928 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_ASSET}"
929 )
930 );
931 assert_eq!(
932 release_asset_url("v1.2.3", CHECKSUMS_ASSET),
933 format!("https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET}")
934 );
935 assert_eq!(
936 release_asset_url("v1.2.3", CHECKSUMS_ASSET_ALT),
937 format!(
938 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET_ALT}"
939 )
940 );
941 }
942
943 #[test]
944 fn test_update_tag_validation_accepts_semver_release_tags() {
945 for tag in [
946 "1.2.3",
947 "v1.2.3",
948 "1.2.3-alpha.1",
949 "v1.2.3-alpha.1",
950 "1.2.3+build.5",
951 "v1.2.3-alpha.1+build.5",
952 ] {
953 assert!(
954 is_valid_update_tag(tag),
955 "expected update tag {tag:?} to be accepted"
956 );
957 }
958 }
959
960 #[test]
961 fn test_update_tag_validation_rejects_non_semver_or_pathlike_tags() {
962 for tag in [
963 "",
964 "v",
965 "..",
966 "v..",
967 "latest",
968 "vlatest",
969 "vv1.2.3",
970 "1.2",
971 "1",
972 "1.2.3/",
973 "1.2.3/../../main",
974 " v1.2.3",
975 "v1.2.3 ",
976 ] {
977 assert!(
978 !is_valid_update_tag(tag),
979 "expected update tag {tag:?} to be rejected"
980 );
981 }
982 }
983
984 #[test]
985 fn test_unix_self_update_verifies_installer_script_before_running() {
986 let script = unix_self_update_script();
987 assert!(script.contains(CHECKSUMS_ASSET));
988 assert!(
989 script.contains(r#"for checksums_url in "$2" "$4" "$5"; do"#),
990 "Unix self-update should try both combined manifests then the standalone per-file checksum"
991 );
992 assert!(script.contains(r#"expected="$candidate""#));
993 assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
994 assert!(script.contains("sha256sum -c -"));
995 assert!(script.contains("shasum -a 256"));
996 assert!(script.contains("openssl dgst -sha256"));
997 assert!(script.contains(r#"exec bash "$script" --easy-mode --verify --version "$3""#));
998 }
999
1000 #[test]
1001 fn test_unix_self_update_threads_standalone_checksum_asset_url() {
1002 let standalone_url = release_asset_url("v1.2.3", UNIX_INSTALL_CHECKSUM_ASSET);
1006 assert_eq!(
1007 standalone_url,
1008 format!(
1009 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_CHECKSUM_ASSET}"
1010 )
1011 );
1012 assert_eq!(UNIX_INSTALL_CHECKSUM_ASSET, "install.sh.sha256");
1013 let script = unix_self_update_script();
1016 assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
1017 assert!(script.contains(r#""$2" "$4" "$5""#));
1018 }
1019
1020 #[test]
1021 fn test_windows_self_update_verifies_installer_script_before_running() {
1022 let script = windows_self_update_script();
1023 assert!(script.contains(CHECKSUMS_ASSET));
1024 assert!(
1025 script.contains(
1026 "foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3], $args[4]))"
1027 ),
1028 "Windows self-update should try both combined manifests then the standalone per-file checksum"
1029 );
1030 assert!(script.contains("Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums"));
1031 assert!(script.contains("if ($Expected)"));
1032 assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
1033 assert!(script.contains("Get-FileHash"));
1034 assert!(script.contains("-EasyMode -Verify -Version $Version"));
1035 assert!(script.contains("Remove-Item -LiteralPath $Temp"));
1036 }
1037
1038 #[test]
1039 fn test_windows_self_update_threads_standalone_checksum_asset_url() {
1040 let standalone_url = release_asset_url("v1.2.3", WINDOWS_INSTALL_CHECKSUM_ASSET);
1044 assert_eq!(
1045 standalone_url,
1046 format!(
1047 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{WINDOWS_INSTALL_CHECKSUM_ASSET}"
1048 )
1049 );
1050 assert_eq!(WINDOWS_INSTALL_CHECKSUM_ASSET, "install.ps1.sha256");
1051 let script = windows_self_update_script();
1052 assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
1053 assert!(script.contains("@($ChecksumsUrl, $args[3], $args[4])"));
1054 }
1055
1056 #[test]
1057 fn test_browser_url_validation_allows_absolute_web_urls() {
1058 assert!(is_browser_url(
1059 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3"
1060 ));
1061 assert!(is_browser_url("http://localhost:8080/releases/v1.2.3"));
1062 assert!(is_browser_url(
1063 "https://github.com/releases/tag/v1.2.3?asset=install.sh&download=1"
1064 ));
1065 }
1066
1067 #[test]
1068 fn test_browser_url_validation_rejects_non_web_or_relative_urls() {
1069 assert!(!is_browser_url(""));
1070 assert!(!is_browser_url("github.com/releases/tag/v1.2.3"));
1071 assert!(!is_browser_url("file:///etc/passwd"));
1072 assert!(!is_browser_url("javascript:alert(1)"));
1073 assert!(!is_browser_url("data:text/html,<script>alert(1)</script>"));
1074 }
1075
1076 #[test]
1077 fn test_url_validation_rejects_userinfo_credentials() -> Result<(), &'static str> {
1078 for url in [
1079 "https://user:pass@github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3",
1080 "http://user@localhost:8080/releases/v1.2.3",
1081 ] {
1082 if is_browser_url(url) {
1083 return Err("browser URL validation accepted embedded credentials");
1084 }
1085 }
1086
1087 let state = UpdateState::default();
1088 let release = GitHubRelease {
1089 tag_name: "v9.9.9".to_string(),
1090 html_url: format!("https://token@github.com/{GITHUB_REPO}/releases/tag/v9.9.9"),
1091 };
1092 if build_update_info("1.0.0", release, &state).is_some() {
1093 return Err("release metadata accepted embedded credentials");
1094 }
1095
1096 for url in [
1097 "https://token@api.github.com/repos/foo/bar",
1098 "https://token:secret@github.com/Dicklesworthstone/coding_agent_session_search/releases",
1099 "http://user@localhost:8080/api",
1100 "http://user:pass@[::1]:8080/api",
1101 ] {
1102 if is_allowed_update_api_url(url) {
1103 return Err("update API override accepted embedded credentials");
1104 }
1105 }
1106
1107 Ok(())
1108 }
1109
1110 #[test]
1111 fn test_release_info_rejects_untrusted_release_notes_urls() {
1112 let state = UpdateState::default();
1113 let release = GitHubRelease {
1114 tag_name: "v9.9.9".to_string(),
1115 html_url: "https://attacker.example/releases/tag/v9.9.9".to_string(),
1116 };
1117 assert!(
1118 build_update_info("1.0.0", release, &state).is_none(),
1119 "release metadata should not surface non-GitHub release notes URLs"
1120 );
1121
1122 let release = GitHubRelease {
1123 tag_name: "v9.9.9".to_string(),
1124 html_url: "file:///tmp/release-notes.html".to_string(),
1125 };
1126 assert!(
1127 build_update_info("1.0.0", release, &state).is_none(),
1128 "release metadata should not surface non-web URLs"
1129 );
1130
1131 let release = GitHubRelease {
1132 tag_name: "v9.9.9".to_string(),
1133 html_url: "https://github.com/other/project/releases/tag/v9.9.9".to_string(),
1134 };
1135 assert!(
1136 build_update_info("1.0.0", release, &state).is_none(),
1137 "release metadata should not surface unrelated GitHub release notes URLs"
1138 );
1139
1140 let release = GitHubRelease {
1141 tag_name: "v9.9.9".to_string(),
1142 html_url: format!(
1143 "https://github.com/{GITHUB_REPO}/releases/download/v9.9.9/install.sh"
1144 ),
1145 };
1146 assert!(
1147 build_update_info("1.0.0", release, &state).is_none(),
1148 "release metadata should not accept release asset download URLs as release notes"
1149 );
1150
1151 let release = GitHubRelease {
1152 tag_name: "v9.9.9".to_string(),
1153 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.8"),
1154 };
1155 assert!(
1156 build_update_info("1.0.0", release, &state).is_none(),
1157 "release metadata should not surface a release notes URL for a different tag"
1158 );
1159
1160 let release = GitHubRelease {
1161 tag_name: "v9.9.9".to_string(),
1162 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9?download=1"),
1163 };
1164 assert!(
1165 build_update_info("1.0.0", release, &state).is_none(),
1166 "release metadata should not accept release notes URLs with query strings"
1167 );
1168
1169 let release = GitHubRelease {
1170 tag_name: "v9.9.9".to_string(),
1171 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9#assets"),
1172 };
1173 assert!(
1174 build_update_info("1.0.0", release, &state).is_none(),
1175 "release metadata should not accept release notes URLs with fragments"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_release_info_accepts_exact_release_notes_url_for_tag() {
1181 let state = UpdateState::default();
1182 let release = GitHubRelease {
1183 tag_name: "v9.9.9+build.5".to_string(),
1184 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9%2Bbuild.5"),
1185 };
1186 let info = build_update_info("1.0.0", release, &state)
1187 .expect("valid GitHub release notes URL should be accepted");
1188
1189 assert_eq!(info.latest_version, "9.9.9+build.5");
1190 assert_eq!(info.tag_name, "v9.9.9+build.5");
1191 assert!(info.is_newer);
1192 }
1193
1194 #[test]
1195 fn test_release_info_accepts_case_insensitive_encoded_plus_in_tag_url() {
1196 let state = UpdateState::default();
1197 let release = GitHubRelease {
1198 tag_name: "v9.9.9+build.5".to_string(),
1199 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9%2bbuild.5"),
1200 };
1201 let info = build_update_info("1.0.0", release, &state)
1202 .expect("percent-encoded plus in a path segment is case-insensitive");
1203
1204 assert_eq!(info.latest_version, "9.9.9+build.5");
1205 assert_eq!(info.tag_name, "v9.9.9+build.5");
1206 assert!(info.is_newer);
1207 }
1208
1209 #[test]
1210 fn test_release_info_rejects_case_changed_tag_url() {
1211 let state = UpdateState::default();
1212 let release = GitHubRelease {
1213 tag_name: "v9.9.9+build.5".to_string(),
1214 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9%2BBUILD.5"),
1215 };
1216
1217 assert!(
1218 build_update_info("1.0.0", release, &state).is_none(),
1219 "tag names are case-sensitive; only percent escape hex case should be normalized"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_trusted_release_notes_url_accepts_full_tag_path_tail() {
1225 assert!(is_trusted_release_notes_url(
1226 &format!("https://github.com/{GITHUB_REPO}/releases/tag/channel/v9.9.9"),
1227 "channel/v9.9.9",
1228 ));
1229 assert!(is_trusted_release_notes_url(
1230 &format!("https://github.com/{GITHUB_REPO}/releases/tag/channel%2Fv9.9.9"),
1231 "channel/v9.9.9",
1232 ));
1233 assert!(!is_trusted_release_notes_url(
1234 &format!("https://github.com/{GITHUB_REPO}/releases/tag/v9.9.9/assets"),
1235 "v9.9.9",
1236 ));
1237 }
1238
1239 #[test]
1240 fn update_state_sidecar_paths_use_pid_timestamp_and_nonce_namespace() -> anyhow::Result<()> {
1241 let sidecar = unique_update_state_temp_path(Path::new("/tmp/update_state.json"));
1242 let next_sidecar = unique_update_state_temp_path(Path::new("/tmp/update_state.json"));
1243 let file_name = sidecar
1244 .file_name()
1245 .and_then(|name| name.to_str())
1246 .ok_or_else(|| anyhow::anyhow!("sidecar path has no UTF-8 file name"))?;
1247 let suffix = file_name
1248 .strip_prefix(".update_state.json.tmp.")
1249 .ok_or_else(|| anyhow::anyhow!("sidecar path lacks expected hidden temp prefix"))?;
1250 let mut parts = suffix.split('.');
1251 let pid = parts
1252 .next()
1253 .ok_or_else(|| anyhow::anyhow!("sidecar suffix lacks process id"))?;
1254 let timestamp = parts
1255 .next()
1256 .ok_or_else(|| anyhow::anyhow!("sidecar suffix lacks timestamp"))?;
1257 let nonce = parts
1258 .next()
1259 .ok_or_else(|| anyhow::anyhow!("sidecar suffix lacks nonce"))?;
1260
1261 anyhow::ensure!(
1262 parts.next().is_none(),
1263 "unexpected sidecar suffix shape: {file_name:?}"
1264 );
1265 anyhow::ensure!(
1266 pid.parse::<u32>()? == std::process::id(),
1267 "sidecar process id should match this process"
1268 );
1269 let _timestamp = timestamp.parse::<u128>()?;
1270 let _nonce = nonce.parse::<u64>()?;
1271 anyhow::ensure!(
1272 sidecar != next_sidecar,
1273 "successive sidecar names should differ",
1274 );
1275 Ok(())
1276 }
1277
1278 #[test]
1279 fn test_release_info_rejects_non_semver_release_tags() {
1280 let state = UpdateState::default();
1281 for tag in ["latest", "..", "vv9.9.9"] {
1282 let release = GitHubRelease {
1283 tag_name: tag.to_string(),
1284 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/{tag}"),
1285 };
1286 assert!(
1287 build_update_info("1.0.0", release, &state).is_none(),
1288 "release metadata should not surface non-SemVer tag {tag:?}"
1289 );
1290 }
1291 }
1292
1293 #[test]
1299 fn test_is_allowed_update_api_url_allows_trusted_https_hosts() {
1300 assert!(is_allowed_update_api_url(
1301 "https://api.github.com/repos/foo"
1302 ));
1303 assert!(is_allowed_update_api_url(
1304 "https://api.github.com/repos/bar/baz"
1305 ));
1306 assert!(is_allowed_update_api_url(
1307 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases"
1308 ));
1309 }
1310
1311 #[test]
1312 fn test_is_allowed_update_api_url_rejects_untrusted_https_hosts() {
1313 assert!(!is_allowed_update_api_url("https://attacker.example.com"));
1314 assert!(!is_allowed_update_api_url("https://example.internal"));
1315 assert!(!is_allowed_update_api_url(
1316 "https://api.github.com.attacker.example/repos/foo"
1317 ));
1318 assert!(!is_allowed_update_api_url(
1319 "https://github.com.attacker.example/releases"
1320 ));
1321 }
1322
1323 #[test]
1324 fn test_is_allowed_update_api_url_allows_http_loopback_only() {
1325 assert!(is_allowed_update_api_url("http://127.0.0.1:8080"));
1326 assert!(is_allowed_update_api_url("http://127.0.0.1:45123/api"));
1327 assert!(is_allowed_update_api_url("http://localhost:1234"));
1328 assert!(is_allowed_update_api_url("http://[::1]:8080"));
1329 }
1330
1331 #[test]
1332 fn test_is_allowed_update_api_url_rejects_non_loopback_http() {
1333 assert!(!is_allowed_update_api_url("http://attacker.com"));
1334 assert!(!is_allowed_update_api_url("http://example.com/api"));
1335 assert!(!is_allowed_update_api_url("http://127.0.0.1.attacker.com"));
1338 assert!(!is_allowed_update_api_url("http://localhost.attacker.com"));
1339 }
1340
1341 #[test]
1342 fn test_is_allowed_update_api_url_rejects_other_schemes() {
1343 assert!(!is_allowed_update_api_url("ftp://api.github.com"));
1344 assert!(!is_allowed_update_api_url("file:///etc/passwd"));
1345 assert!(!is_allowed_update_api_url("gopher://example.com"));
1346 assert!(!is_allowed_update_api_url(""));
1347 assert!(!is_allowed_update_api_url("api.github.com"));
1348 assert!(!is_allowed_update_api_url("https://"));
1351 assert!(!is_allowed_update_api_url("https:///path"));
1352 }
1353
1354 #[test]
1355 #[serial]
1356 fn test_state_should_check() {
1357 let mut state = UpdateState::default();
1358 assert!(state.should_check()); state.mark_checked();
1361 assert!(!state.should_check()); state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1365 assert!(state.should_check()); state.last_check_ts = now_unix() + CHECK_INTERVAL_SECS as i64;
1370 assert!(state.should_check());
1371 }
1372
1373 #[test]
1374 #[serial]
1375 fn test_skip_version() {
1376 let mut state = UpdateState::default();
1377 assert!(!state.is_skipped("1.0.0"));
1378
1379 state.skip_version("1.0.0");
1380 assert!(state.is_skipped("1.0.0"));
1381 assert!(!state.is_skipped("1.0.1"));
1382
1383 state.clear_skip();
1384 assert!(!state.is_skipped("1.0.0"));
1385 }
1386
1387 #[test]
1388 #[serial]
1389 fn update_check_state_remains_functional_without_session_dismiss_stub() {
1390 let state = UpdateState::default();
1391 assert!(
1392 state.should_check(),
1393 "fresh state should still trigger checks"
1394 );
1395 assert!(
1396 !state.is_skipped("9.9.9"),
1397 "default state should not invent skipped versions"
1398 );
1399 }
1400
1401 #[test]
1402 #[serial]
1403 fn test_update_info_should_show() {
1404 let info = UpdateInfo {
1405 latest_version: "1.0.0".into(),
1406 tag_name: "v1.0.0".into(),
1407 current_version: "0.9.0".into(),
1408 release_url: "https://example.com".into(),
1409 is_newer: true,
1410 is_skipped: false,
1411 };
1412 assert!(info.should_show());
1413
1414 let skipped = UpdateInfo {
1415 is_skipped: true,
1416 ..info.clone()
1417 };
1418 assert!(!skipped.should_show());
1419
1420 let not_newer = UpdateInfo {
1421 is_newer: false,
1422 ..info
1423 };
1424 assert!(!not_newer.should_show());
1425 }
1426
1427 #[test]
1432 #[serial]
1433 fn test_version_comparison_upgrade_scenarios() {
1434 let test_cases = vec![
1436 ("0.1.50", "0.1.52", true, "patch upgrade"),
1437 ("0.1.52", "0.2.0", true, "minor upgrade"),
1438 ("0.1.52", "1.0.0", true, "major upgrade"),
1439 ("0.1.52", "0.1.52", false, "same version"),
1440 ("0.1.52", "0.1.51", false, "downgrade"),
1441 ("0.1.52", "0.1.52-alpha", false, "prerelease is older"),
1442 (
1443 "0.1.52-alpha",
1444 "0.1.52",
1445 true,
1446 "stable is newer than prerelease",
1447 ),
1448 ];
1449
1450 for (current, latest, expected_newer, scenario) in test_cases {
1451 let current_ver = Version::parse(current).expect("valid current version");
1452 let latest_ver = Version::parse(latest).expect("valid latest version");
1453 let is_newer = latest_ver > current_ver;
1454 assert_eq!(
1455 is_newer, expected_newer,
1456 "scenario '{}': {} -> {} should be is_newer={}",
1457 scenario, current, latest, expected_newer
1458 );
1459 }
1460 }
1461
1462 #[test]
1463 #[serial]
1464 fn test_update_state_persistence_round_trip() {
1465 let temp_dir = tempfile::TempDir::new().unwrap();
1466 let state_file = temp_dir.path().join("update_state.json");
1467
1468 let mut state = UpdateState {
1470 last_check_ts: 1234567890,
1471 skipped_version: Some("0.1.50".to_string()),
1472 };
1473
1474 let json = serde_json::to_string_pretty(&state).unwrap();
1476 std::fs::write(&state_file, &json).unwrap();
1477
1478 let loaded: UpdateState =
1480 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1481
1482 assert_eq!(loaded.last_check_ts, 1234567890);
1483 assert_eq!(loaded.skipped_version, Some("0.1.50".to_string()));
1484 assert!(loaded.is_skipped("0.1.50"));
1485 assert!(!loaded.is_skipped("0.1.51"));
1486
1487 state.skip_version("0.1.51");
1489 state.mark_checked();
1490 let json = serde_json::to_string_pretty(&state).unwrap();
1491 std::fs::write(&state_file, &json).unwrap();
1492
1493 let loaded: UpdateState =
1494 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1495 assert!(loaded.is_skipped("0.1.51"));
1496 assert!(!loaded.is_skipped("0.1.50")); }
1498
1499 #[cfg(unix)]
1500 #[test]
1501 fn update_state_replacement_path_entry_exists_detects_dangling_symlink() -> Result<()> {
1502 use std::os::unix::fs::symlink;
1503
1504 let temp_dir = tempfile::TempDir::new()?;
1505 let state_file = temp_dir.path().join("update_state.json");
1506 let missing_target = temp_dir.path().join("missing-update-state.json");
1507 symlink(&missing_target, &state_file)?;
1508
1509 match std::fs::metadata(&state_file) {
1510 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1511 Ok(_) => return Err(anyhow::anyhow!("dangling update state symlink resolved")),
1512 Err(err) => return Err(err.into()),
1513 }
1514 if !update_state_path_entry_exists(&state_file)? {
1515 return Err(anyhow::anyhow!(
1516 "update state replacement entry check missed dangling symlink {}",
1517 state_file.display()
1518 ));
1519 }
1520
1521 Ok(())
1522 }
1523
1524 #[cfg(unix)]
1525 fn install_update_state_symlink(data_dir: &std::path::Path) -> (tempfile::TempDir, PathBuf) {
1526 use std::os::unix::fs::symlink;
1527
1528 let outside_dir = tempfile::TempDir::new().unwrap();
1529 let target_file = outside_dir.path().join("target-update-state.json");
1530 std::fs::write(&target_file, "untouched").unwrap();
1531 symlink(&target_file, data_dir.join("update_state.json")).unwrap();
1532 (outside_dir, target_file)
1533 }
1534
1535 #[cfg(unix)]
1536 fn assert_update_state_symlink_was_replaced(
1537 data_dir: &std::path::Path,
1538 target_file: &std::path::Path,
1539 expected_ts: i64,
1540 ) {
1541 let state_file = data_dir.join("update_state.json");
1542 assert_eq!(
1543 std::fs::read_to_string(target_file).unwrap(),
1544 "untouched",
1545 "update state persistence must not follow an existing symlink"
1546 );
1547 assert!(
1548 !std::fs::symlink_metadata(&state_file)
1549 .unwrap()
1550 .file_type()
1551 .is_symlink(),
1552 "state path should be replaced with a regular JSON file"
1553 );
1554
1555 let loaded: UpdateState =
1556 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1557 assert_eq!(loaded.last_check_ts, expected_ts);
1558 assert_eq!(loaded.skipped_version, Some("0.2.0".to_string()));
1559 }
1560
1561 #[cfg(unix)]
1562 #[test]
1563 #[serial]
1564 fn test_update_state_save_replaces_existing_symlink() {
1565 let temp_dir = tempfile::TempDir::new().unwrap();
1566 let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1567 unsafe {
1568 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1569 }
1570
1571 let state = UpdateState {
1572 last_check_ts: 42,
1573 skipped_version: Some("0.2.0".to_string()),
1574 };
1575 state.save().unwrap();
1576
1577 unsafe {
1578 std::env::remove_var("CASS_DATA_DIR");
1579 }
1580 assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 42);
1581 }
1582
1583 #[cfg(unix)]
1584 #[test]
1585 #[serial]
1586 fn test_update_state_save_async_replaces_existing_symlink() {
1587 let temp_dir = tempfile::TempDir::new().unwrap();
1588 let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1589 unsafe {
1590 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1591 }
1592
1593 let state = UpdateState {
1594 last_check_ts: 43,
1595 skipped_version: Some("0.2.0".to_string()),
1596 };
1597 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1598 .build()
1599 .expect("build test runtime");
1600 runtime.block_on(state.save_async()).unwrap();
1601
1602 unsafe {
1603 std::env::remove_var("CASS_DATA_DIR");
1604 }
1605 assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 43);
1606 }
1607
1608 #[test]
1609 #[serial]
1610 fn test_update_info_upgrade_workflow() {
1611 let info = UpdateInfo {
1615 latest_version: "0.2.0".into(),
1616 tag_name: "v0.2.0".into(),
1617 current_version: "0.1.52".into(),
1618 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0".into(),
1619 is_newer: true,
1620 is_skipped: false,
1621 };
1622 assert!(info.should_show(), "should show upgrade banner");
1623 assert!(info.is_newer, "should detect newer version");
1624
1625 let mut state = UpdateState::default();
1627 state.skip_version(&info.latest_version);
1628 assert!(state.is_skipped(&info.latest_version));
1629
1630 let info_after_skip = UpdateInfo {
1632 is_skipped: state.is_skipped(&info.latest_version),
1633 ..info.clone()
1634 };
1635 assert!(
1636 !info_after_skip.should_show(),
1637 "should not show banner for skipped version"
1638 );
1639
1640 state.clear_skip();
1642 let newer_info = UpdateInfo {
1643 latest_version: "0.3.0".into(),
1644 tag_name: "v0.3.0".into(),
1645 current_version: "0.1.52".into(),
1646 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0".into(),
1647 is_newer: true,
1648 is_skipped: false,
1649 };
1650 assert!(
1651 newer_info.should_show(),
1652 "should show banner for version newer than skipped"
1653 );
1654 }
1655
1656 #[test]
1657 #[serial]
1658 fn test_check_interval_respects_cadence() {
1659 let mut state = UpdateState::default();
1660
1661 assert!(state.should_check());
1663
1664 state.mark_checked();
1666 assert!(!state.should_check());
1667
1668 state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1670 assert!(!state.should_check());
1671
1672 state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1674 assert!(state.should_check());
1675 }
1676
1677 #[test]
1678 #[serial]
1679 fn test_github_repo_constant_is_valid() {
1680 assert!(GITHUB_REPO.contains('/'));
1682 let parts: Vec<&str> = GITHUB_REPO.split('/').collect();
1683 assert_eq!(parts.len(), 2, "should be owner/repo format");
1684 assert!(!parts[0].is_empty(), "owner should not be empty");
1685 assert!(!parts[1].is_empty(), "repo should not be empty");
1686 assert_eq!(parts[0], "Dicklesworthstone");
1687 assert_eq!(parts[1], "coding_agent_session_search");
1688 }
1689
1690 fn http_response(status: u16, body: &str) -> String {
1697 format!(
1698 "HTTP/1.1 {} {}\r\n\
1699 Content-Type: application/json\r\n\
1700 Content-Length: {}\r\n\
1701 Connection: close\r\n\
1702 \r\n\
1703 {}",
1704 status,
1705 match status {
1706 200 => "OK",
1707 404 => "Not Found",
1708 500 => "Internal Server Error",
1709 _ => "Unknown",
1710 },
1711 body.len(),
1712 body
1713 )
1714 }
1715
1716 fn start_test_server(
1718 response_body: &str,
1719 status: u16,
1720 ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1721 use std::io::{ErrorKind, Read, Write};
1722 use std::net::TcpListener;
1723 use std::sync::mpsc;
1724 use std::time::{Duration, Instant};
1725
1726 let listener = TcpListener::bind("127.0.0.1:0").expect("bind to ephemeral port");
1727 let addr = listener.local_addr().expect("get local addr");
1728 let _ = listener.set_nonblocking(true);
1729
1730 let response = http_response(status, response_body);
1731 let (ready_tx, ready_rx) = mpsc::channel();
1732
1733 let handle = std::thread::spawn(move || {
1734 let _ = ready_tx.send(());
1735 let deadline = Instant::now() + Duration::from_secs(2);
1736 let mut stream = loop {
1737 match listener.accept() {
1738 Ok((stream, _)) => break stream,
1739 Err(err)
1740 if err.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
1741 {
1742 std::thread::sleep(Duration::from_millis(5));
1743 }
1744 Err(_) => return,
1745 }
1746 };
1747
1748 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1749 let mut buf = [0u8; 4096];
1750 match stream.read(&mut buf) {
1751 Ok(_) => {}
1752 Err(err)
1753 if matches!(
1754 err.kind(),
1755 ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::UnexpectedEof
1756 ) => {}
1757 Err(_) => return,
1758 }
1759
1760 if stream.write_all(response.as_bytes()).is_ok() {
1761 let _ = stream.flush();
1762 std::thread::sleep(Duration::from_millis(25));
1763 }
1764 });
1765
1766 let _ = ready_rx.recv_timeout(std::time::Duration::from_secs(1));
1767
1768 (addr, handle)
1769 }
1770
1771 #[test]
1772 #[serial]
1773 fn integration_fetch_release_success() {
1774 let release_json = r#"{
1776 "tag_name": "v0.2.0",
1777 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1778 }"#;
1779
1780 let (addr, handle) = start_test_server(release_json, 200);
1781
1782 unsafe {
1786 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1787 }
1788
1789 let result = fetch_latest_release_blocking();
1791
1792 unsafe {
1794 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1795 }
1796
1797 handle.join().expect("server thread");
1798
1799 let release = result.expect("fetch should succeed");
1800 assert_eq!(release.tag_name, "v0.2.0");
1801 assert!(release.html_url.contains("v0.2.0"));
1802 }
1803
1804 #[test]
1805 #[serial]
1806 fn integration_fetch_release_404_error() {
1807 let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1808
1809 unsafe {
1810 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1811 }
1812
1813 let result = fetch_latest_release_blocking();
1814
1815 unsafe {
1816 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1817 }
1818
1819 handle.join().expect("server thread");
1820
1821 assert!(result.is_err(), "should return error for 404");
1822 let err = result.unwrap_err();
1823 assert!(
1824 err.to_string().contains("404") || err.to_string().contains("Not Found"),
1825 "error should mention 404: {}",
1826 err
1827 );
1828 }
1829
1830 #[test]
1831 #[serial]
1832 fn integration_fetch_release_malformed_json() {
1833 let (addr, handle) = start_test_server("this is not json", 200);
1834
1835 unsafe {
1836 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1837 }
1838
1839 let result = fetch_latest_release_blocking();
1840
1841 unsafe {
1842 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1843 }
1844
1845 handle.join().expect("server thread");
1846
1847 assert!(result.is_err(), "should return error for malformed JSON");
1848 }
1849
1850 #[test]
1851 #[serial]
1852 fn integration_fetch_release_missing_fields() {
1853 let incomplete_json = r#"{"some_other_field": "value"}"#;
1855
1856 let (addr, handle) = start_test_server(incomplete_json, 200);
1857
1858 unsafe {
1859 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1860 }
1861
1862 let result = fetch_latest_release_blocking();
1863
1864 unsafe {
1865 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1866 }
1867
1868 handle.join().expect("server thread");
1869
1870 assert!(result.is_err(), "should error on missing required fields");
1872 }
1873
1874 #[test]
1875 #[serial]
1876 fn integration_fetch_release_server_error() {
1877 let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1878
1879 unsafe {
1880 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1881 }
1882
1883 let result = fetch_latest_release_blocking();
1884
1885 unsafe {
1886 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1887 }
1888
1889 handle.join().expect("server thread");
1890
1891 assert!(result.is_err(), "should return error for 500");
1892 }
1893
1894 #[test]
1895 #[serial]
1896 fn integration_version_comparison_with_real_fetch() {
1897 let release_json = r#"{
1899 "tag_name": "v0.3.0",
1900 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1901 }"#;
1902
1903 let (addr, handle) = start_test_server(release_json, 200);
1904
1905 unsafe {
1906 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1907 }
1908
1909 let result = fetch_latest_release_blocking();
1910
1911 unsafe {
1912 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1913 }
1914
1915 handle.join().expect("server thread");
1916
1917 let release = result.expect("fetch should succeed");
1918
1919 let latest_str = release.tag_name.trim_start_matches('v');
1921 let latest = Version::parse(latest_str).expect("parse latest version");
1922 let current = Version::parse("0.1.50").expect("parse current version");
1923
1924 assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1925 }
1926
1927 #[test]
1928 #[serial]
1929 fn integration_prerelease_version_handling() {
1930 let release_json = r#"{
1932 "tag_name": "v0.2.0-beta.1",
1933 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1934 }"#;
1935
1936 let (addr, handle) = start_test_server(release_json, 200);
1937
1938 unsafe {
1939 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1940 }
1941
1942 let result = fetch_latest_release_blocking();
1943
1944 unsafe {
1945 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1946 }
1947
1948 handle.join().expect("server thread");
1949
1950 let release = result.expect("fetch should succeed");
1951 let latest_str = release.tag_name.trim_start_matches('v');
1952 let latest = Version::parse(latest_str).expect("parse prerelease version");
1953
1954 let stable = Version::parse("0.2.0").expect("parse stable version");
1956 assert!(
1957 latest < stable,
1958 "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1959 );
1960
1961 let older = Version::parse("0.1.50").expect("parse older version");
1963 assert!(
1964 latest > older,
1965 "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1966 );
1967 }
1968
1969 #[test]
1970 #[serial]
1971 fn integration_connection_refused_is_offline_friendly() {
1972 unsafe {
1974 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1975 }
1976
1977 let result = fetch_latest_release_blocking();
1978
1979 unsafe {
1980 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1981 }
1982
1983 assert!(
1985 result.is_err(),
1986 "should return error when server unreachable"
1987 );
1988 let err = result.unwrap_err();
1990 let err_chain = format!("{:?}", err).to_lowercase();
1991 assert!(
1992 err_chain.contains("connection")
1993 || err_chain.contains("connect")
1994 || err_chain.contains("refused")
1995 || err_chain.contains("fetch")
1996 || err_chain.contains("os error"),
1997 "should be a network/fetch error: {}",
1998 err_chain
1999 );
2000 }
2001
2002 #[test]
2003 #[serial]
2004 fn integration_failed_sync_check_does_not_throttle_future_checks() {
2005 let temp_dir = tempfile::TempDir::new().unwrap();
2006 let state_file = temp_dir.path().join("update_state.json");
2007 unsafe {
2008 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
2009 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
2010 std::env::remove_var("CASS_SKIP_UPDATE");
2011 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
2012 std::env::remove_var("TUI_HEADLESS");
2013 std::env::remove_var("CI");
2014 }
2015
2016 let result = check_for_updates_sync("0.1.0");
2017 assert!(result.is_none(), "offline sync check should fail quietly");
2018
2019 assert!(
2020 !state_file.exists(),
2021 "failed sync checks must not persist cadence state"
2022 );
2023
2024 unsafe {
2025 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2026 std::env::remove_var("CASS_DATA_DIR");
2027 }
2028 }
2029
2030 #[test]
2031 #[serial]
2032 fn integration_failed_async_check_does_not_throttle_future_checks() {
2033 let temp_dir = tempfile::TempDir::new().unwrap();
2034 let state_file = temp_dir.path().join("update_state.json");
2035 unsafe {
2036 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
2037 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
2038 std::env::remove_var("CASS_SKIP_UPDATE");
2039 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
2040 std::env::remove_var("TUI_HEADLESS");
2041 std::env::remove_var("CI");
2042 }
2043
2044 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
2045 .build()
2046 .expect("build test runtime");
2047 let result = runtime.block_on(check_for_updates("0.1.0"));
2048 assert!(result.is_none(), "offline async check should fail quietly");
2049
2050 assert!(
2051 !state_file.exists(),
2052 "failed async checks must not persist cadence state"
2053 );
2054
2055 unsafe {
2056 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2057 std::env::remove_var("CASS_DATA_DIR");
2058 }
2059 }
2060
2061 #[cfg(unix)]
2062 #[test]
2063 #[serial]
2064 fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
2065 use std::os::unix::fs::PermissionsExt;
2066
2067 let temp_dir = tempfile::TempDir::new().unwrap();
2068 let state_file = temp_dir.path().join("update_state.json");
2069 let state = UpdateState {
2070 last_check_ts: now_unix(),
2071 skipped_version: None,
2072 };
2073 std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
2074
2075 let release_json = r#"{
2076 "tag_name": "v9.9.9",
2077 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
2078 }"#;
2079 let (addr, handle) = start_test_server(release_json, 200);
2080
2081 let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
2082 let file_metadata = std::fs::metadata(&state_file).unwrap();
2083 let dir_mode = dir_metadata.permissions().mode();
2084 let file_mode = file_metadata.permissions().mode();
2085
2086 let mut readonly_dir = dir_metadata.permissions();
2087 readonly_dir.set_mode(0o555);
2088 std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
2089
2090 let mut readonly_file = file_metadata.permissions();
2091 readonly_file.set_mode(0o444);
2092 std::fs::set_permissions(&state_file, readonly_file).unwrap();
2093
2094 unsafe {
2095 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
2096 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
2097 std::env::remove_var("CASS_SKIP_UPDATE");
2098 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
2099 std::env::remove_var("TUI_HEADLESS");
2100 std::env::remove_var("CI");
2101 }
2102
2103 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
2104 .build()
2105 .expect("build test runtime");
2106 let result = runtime.block_on(force_check("0.1.0"));
2107
2108 let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
2109 restore_file.set_mode(file_mode);
2110 std::fs::set_permissions(&state_file, restore_file).unwrap();
2111
2112 let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
2113 restore_dir.set_mode(dir_mode);
2114 std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
2115
2116 unsafe {
2117 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2118 std::env::remove_var("CASS_DATA_DIR");
2119 }
2120
2121 handle.join().expect("server thread");
2122
2123 let info = result.expect("force check should bypass cadence and succeed");
2124 assert_eq!(info.latest_version, "9.9.9");
2125 assert!(info.is_newer);
2126 }
2127
2128 #[test]
2129 #[serial]
2130 fn integration_blocking_fetch_release_success_v1() {
2131 let release_json = r#"{
2133 "tag_name": "v1.0.0",
2134 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
2135 }"#;
2136
2137 let (addr, handle) = start_test_server(release_json, 200);
2138
2139 unsafe {
2140 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
2141 }
2142
2143 let result = fetch_latest_release_blocking();
2144
2145 unsafe {
2146 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2147 }
2148
2149 handle.join().expect("server thread");
2150
2151 let release = result.expect("blocking fetch should succeed");
2152 assert_eq!(release.tag_name, "v1.0.0");
2153 }
2154
2155 #[test]
2156 #[serial]
2157 fn integration_blocking_fetch_release_403_error() {
2158 let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
2159
2160 unsafe {
2161 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
2162 }
2163
2164 let result = fetch_latest_release_blocking();
2165
2166 unsafe {
2167 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2168 }
2169
2170 handle.join().expect("server thread");
2171
2172 assert!(result.is_err(), "should error on 403");
2173 }
2174
2175 #[test]
2176 #[serial]
2177 fn integration_release_api_base_url_default() {
2178 unsafe {
2180 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2181 }
2182
2183 let url = release_api_base_url();
2184 assert!(
2185 url.contains("api.github.com"),
2186 "default should use GitHub API"
2187 );
2188 assert!(
2189 url.contains(GITHUB_REPO),
2190 "default should include repo path"
2191 );
2192 }
2193
2194 #[test]
2195 #[serial]
2196 fn integration_release_api_base_url_override() {
2197 let custom_url = "http://localhost:8080/api";
2198 unsafe {
2199 std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
2200 }
2201
2202 let url = release_api_base_url();
2203
2204 unsafe {
2205 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
2206 }
2207
2208 assert_eq!(url, custom_url, "should use custom URL from env var");
2209 }
2210
2211 #[test]
2212 #[serial]
2213 fn integration_http_timeout_is_reasonable() {
2214 const _: () = {
2215 assert!(
2217 HTTP_TIMEOUT_SECS <= 10,
2218 "HTTP timeout should be short to avoid blocking startup"
2219 );
2220 assert!(
2221 HTTP_TIMEOUT_SECS >= 3,
2222 "HTTP timeout should be long enough for slow networks"
2223 );
2224 };
2225 }
2226
2227 #[test]
2228 #[serial]
2229 fn integration_check_interval_is_reasonable() {
2230 const _: () = {
2231 assert!(
2233 CHECK_INTERVAL_SECS >= 3600,
2234 "should not check more than once per hour"
2235 );
2236 assert!(
2237 CHECK_INTERVAL_SECS <= 86400,
2238 "should check at least once per day"
2239 );
2240 };
2241 }
2242}