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