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(handle) = asupersync::runtime::Runtime::current_handle() {
826 let (tx, rx) = std::sync::mpsc::channel();
827
828 handle
829 .try_spawn_with_cx(move |cx| async move {
830 let _ = tx.send(fetch_latest_release_with_cx(&cx).await);
831 })
832 .context("spawning update check task")?;
833
834 loop {
835 match rx.try_recv() {
836 Ok(result) => return result,
837 Err(TryRecvError::Empty) => asupersync::runtime::yield_now().await,
838 Err(TryRecvError::Disconnected) => {
839 anyhow::bail!("update check task exited before returning a result");
840 }
841 }
842 }
843 }
844
845 let cx = asupersync::Cx::current().context("update check requires an active asupersync Cx")?;
846 fetch_latest_release_with_cx(&cx).await
847}
848
849async fn fetch_latest_release_with_cx(cx: &asupersync::Cx) -> Result<GitHubRelease> {
850 let url = format!("{}/releases/latest", release_api_base_url());
851 let client = asupersync::http::h1::HttpClient::builder()
852 .user_agent(concat!("cass/", env!("CARGO_PKG_VERSION")))
853 .build();
854 let response = asupersync::time::timeout(
855 cx.now(),
856 Duration::from_secs(HTTP_TIMEOUT_SECS),
857 client.request(
858 cx,
859 asupersync::http::h1::Method::Get,
860 &url,
861 vec![(
862 "Accept".to_string(),
863 "application/vnd.github.v3+json".to_string(),
864 )],
865 Vec::new(),
866 ),
867 )
868 .await
869 .map_err(|e| anyhow::anyhow!("timed out fetching release: {e}"))?
870 .context("fetching release")?;
871
872 if !response.is_success() {
873 anyhow::bail!("GitHub API returned {}", response.status);
874 }
875
876 response
877 .json::<GitHubRelease>()
878 .context("parsing release JSON")
879}
880
881fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
883 asupersync::runtime::RuntimeBuilder::current_thread()
884 .build()
885 .context("building update-check runtime")?
886 .block_on(fetch_latest_release())
887}
888
889pub fn spawn_update_check(
892 current_version: String,
893) -> std::sync::mpsc::Receiver<Option<UpdateInfo>> {
894 let (tx, rx) = std::sync::mpsc::channel();
895 if updates_disabled() {
896 let _ = tx.send(None);
897 return rx;
898 }
899 std::thread::spawn(move || {
900 let result = check_for_updates_sync(¤t_version);
901 let _ = tx.send(result);
902 });
903 rx
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use serial_test::serial;
910
911 #[test]
912 fn test_release_asset_url_uses_immutable_release_downloads() {
913 assert_eq!(
914 release_asset_url("v1.2.3", UNIX_INSTALL_ASSET),
915 format!(
916 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_ASSET}"
917 )
918 );
919 assert_eq!(
920 release_asset_url("v1.2.3", CHECKSUMS_ASSET),
921 format!("https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET}")
922 );
923 assert_eq!(
924 release_asset_url("v1.2.3", CHECKSUMS_ASSET_ALT),
925 format!(
926 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET_ALT}"
927 )
928 );
929 }
930
931 #[test]
932 fn test_update_tag_validation_accepts_semver_release_tags() {
933 for tag in [
934 "1.2.3",
935 "v1.2.3",
936 "1.2.3-alpha.1",
937 "v1.2.3-alpha.1",
938 "1.2.3+build.5",
939 "v1.2.3-alpha.1+build.5",
940 ] {
941 assert!(
942 is_valid_update_tag(tag),
943 "expected update tag {tag:?} to be accepted"
944 );
945 }
946 }
947
948 #[test]
949 fn test_update_tag_validation_rejects_non_semver_or_pathlike_tags() {
950 for tag in [
951 "",
952 "v",
953 "..",
954 "v..",
955 "latest",
956 "vlatest",
957 "vv1.2.3",
958 "1.2",
959 "1",
960 "1.2.3/",
961 "1.2.3/../../main",
962 " v1.2.3",
963 "v1.2.3 ",
964 ] {
965 assert!(
966 !is_valid_update_tag(tag),
967 "expected update tag {tag:?} to be rejected"
968 );
969 }
970 }
971
972 #[test]
973 fn test_unix_self_update_verifies_installer_script_before_running() {
974 let script = unix_self_update_script();
975 assert!(script.contains(CHECKSUMS_ASSET));
976 assert!(
977 script.contains(r#"for checksums_url in "$2" "$4"; do"#),
978 "Unix self-update should try both checksum manifest URLs"
979 );
980 assert!(script.contains(r#"expected="$candidate""#));
981 assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
982 assert!(script.contains("sha256sum -c -"));
983 assert!(script.contains("shasum -a 256"));
984 assert!(script.contains("openssl dgst -sha256"));
985 assert!(script.contains(r#"exec bash "$script" --easy-mode --verify --version "$3""#));
986 }
987
988 #[test]
989 fn test_windows_self_update_verifies_installer_script_before_running() {
990 let script = windows_self_update_script();
991 assert!(script.contains(CHECKSUMS_ASSET));
992 assert!(
993 script.contains("foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3]))"),
994 "Windows self-update should try both checksum manifest URLs"
995 );
996 assert!(script.contains("Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums"));
997 assert!(script.contains("if ($Expected)"));
998 assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
999 assert!(script.contains("Get-FileHash"));
1000 assert!(script.contains("-EasyMode -Verify -Version $Version"));
1001 assert!(script.contains("Remove-Item -LiteralPath $Temp"));
1002 }
1003
1004 #[test]
1005 fn test_browser_url_validation_allows_absolute_web_urls() {
1006 assert!(is_browser_url(
1007 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3"
1008 ));
1009 assert!(is_browser_url("http://localhost:8080/releases/v1.2.3"));
1010 assert!(is_browser_url(
1011 "https://github.com/releases/tag/v1.2.3?asset=install.sh&download=1"
1012 ));
1013 }
1014
1015 #[test]
1016 fn test_browser_url_validation_rejects_non_web_or_relative_urls() {
1017 assert!(!is_browser_url(""));
1018 assert!(!is_browser_url("github.com/releases/tag/v1.2.3"));
1019 assert!(!is_browser_url("file:///etc/passwd"));
1020 assert!(!is_browser_url("javascript:alert(1)"));
1021 assert!(!is_browser_url("data:text/html,<script>alert(1)</script>"));
1022 }
1023
1024 #[test]
1025 fn test_url_validation_rejects_userinfo_credentials() -> Result<(), &'static str> {
1026 for url in [
1027 "https://user:pass@github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3",
1028 "http://user@localhost:8080/releases/v1.2.3",
1029 ] {
1030 if is_browser_url(url) {
1031 return Err("browser URL validation accepted embedded credentials");
1032 }
1033 }
1034
1035 let state = UpdateState::default();
1036 let release = GitHubRelease {
1037 tag_name: "v9.9.9".to_string(),
1038 html_url: format!("https://token@github.com/{GITHUB_REPO}/releases/tag/v9.9.9"),
1039 };
1040 if build_update_info("1.0.0", release, &state).is_some() {
1041 return Err("release metadata accepted embedded credentials");
1042 }
1043
1044 for url in [
1045 "https://token@api.github.com/repos/foo/bar",
1046 "https://token:secret@github.com/Dicklesworthstone/coding_agent_session_search/releases",
1047 "http://user@localhost:8080/api",
1048 "http://user:pass@[::1]:8080/api",
1049 ] {
1050 if is_allowed_update_api_url(url) {
1051 return Err("update API override accepted embedded credentials");
1052 }
1053 }
1054
1055 Ok(())
1056 }
1057
1058 #[test]
1059 fn test_release_info_rejects_untrusted_release_notes_urls() {
1060 let state = UpdateState::default();
1061 let release = GitHubRelease {
1062 tag_name: "v9.9.9".to_string(),
1063 html_url: "https://attacker.example/releases/tag/v9.9.9".to_string(),
1064 };
1065 assert!(
1066 build_update_info("1.0.0", release, &state).is_none(),
1067 "release metadata should not surface non-GitHub release notes URLs"
1068 );
1069
1070 let release = GitHubRelease {
1071 tag_name: "v9.9.9".to_string(),
1072 html_url: "file:///tmp/release-notes.html".to_string(),
1073 };
1074 assert!(
1075 build_update_info("1.0.0", release, &state).is_none(),
1076 "release metadata should not surface non-web URLs"
1077 );
1078
1079 let release = GitHubRelease {
1080 tag_name: "v9.9.9".to_string(),
1081 html_url: "https://github.com/other/project/releases/tag/v9.9.9".to_string(),
1082 };
1083 assert!(
1084 build_update_info("1.0.0", release, &state).is_none(),
1085 "release metadata should not surface unrelated GitHub release notes URLs"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_release_info_rejects_non_semver_release_tags() {
1091 let state = UpdateState::default();
1092 for tag in ["latest", "..", "vv9.9.9"] {
1093 let release = GitHubRelease {
1094 tag_name: tag.to_string(),
1095 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/{tag}"),
1096 };
1097 assert!(
1098 build_update_info("1.0.0", release, &state).is_none(),
1099 "release metadata should not surface non-SemVer tag {tag:?}"
1100 );
1101 }
1102 }
1103
1104 #[test]
1110 fn test_is_allowed_update_api_url_allows_trusted_https_hosts() {
1111 assert!(is_allowed_update_api_url(
1112 "https://api.github.com/repos/foo"
1113 ));
1114 assert!(is_allowed_update_api_url(
1115 "https://api.github.com/repos/bar/baz"
1116 ));
1117 assert!(is_allowed_update_api_url(
1118 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases"
1119 ));
1120 }
1121
1122 #[test]
1123 fn test_is_allowed_update_api_url_rejects_untrusted_https_hosts() {
1124 assert!(!is_allowed_update_api_url("https://attacker.example.com"));
1125 assert!(!is_allowed_update_api_url("https://example.internal"));
1126 assert!(!is_allowed_update_api_url(
1127 "https://api.github.com.attacker.example/repos/foo"
1128 ));
1129 assert!(!is_allowed_update_api_url(
1130 "https://github.com.attacker.example/releases"
1131 ));
1132 }
1133
1134 #[test]
1135 fn test_is_allowed_update_api_url_allows_http_loopback_only() {
1136 assert!(is_allowed_update_api_url("http://127.0.0.1:8080"));
1137 assert!(is_allowed_update_api_url("http://127.0.0.1:45123/api"));
1138 assert!(is_allowed_update_api_url("http://localhost:1234"));
1139 assert!(is_allowed_update_api_url("http://[::1]:8080"));
1140 }
1141
1142 #[test]
1143 fn test_is_allowed_update_api_url_rejects_non_loopback_http() {
1144 assert!(!is_allowed_update_api_url("http://attacker.com"));
1145 assert!(!is_allowed_update_api_url("http://example.com/api"));
1146 assert!(!is_allowed_update_api_url("http://127.0.0.1.attacker.com"));
1149 assert!(!is_allowed_update_api_url("http://localhost.attacker.com"));
1150 }
1151
1152 #[test]
1153 fn test_is_allowed_update_api_url_rejects_other_schemes() {
1154 assert!(!is_allowed_update_api_url("ftp://api.github.com"));
1155 assert!(!is_allowed_update_api_url("file:///etc/passwd"));
1156 assert!(!is_allowed_update_api_url("gopher://example.com"));
1157 assert!(!is_allowed_update_api_url(""));
1158 assert!(!is_allowed_update_api_url("api.github.com"));
1159 assert!(!is_allowed_update_api_url("https://"));
1162 assert!(!is_allowed_update_api_url("https:///path"));
1163 }
1164
1165 #[test]
1166 #[serial]
1167 fn test_state_should_check() {
1168 let mut state = UpdateState::default();
1169 assert!(state.should_check()); state.mark_checked();
1172 assert!(!state.should_check()); state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1176 assert!(state.should_check()); state.last_check_ts = now_unix() + CHECK_INTERVAL_SECS as i64;
1181 assert!(state.should_check());
1182 }
1183
1184 #[test]
1185 #[serial]
1186 fn test_skip_version() {
1187 let mut state = UpdateState::default();
1188 assert!(!state.is_skipped("1.0.0"));
1189
1190 state.skip_version("1.0.0");
1191 assert!(state.is_skipped("1.0.0"));
1192 assert!(!state.is_skipped("1.0.1"));
1193
1194 state.clear_skip();
1195 assert!(!state.is_skipped("1.0.0"));
1196 }
1197
1198 #[test]
1199 #[serial]
1200 fn update_check_state_remains_functional_without_session_dismiss_stub() {
1201 let state = UpdateState::default();
1202 assert!(
1203 state.should_check(),
1204 "fresh state should still trigger checks"
1205 );
1206 assert!(
1207 !state.is_skipped("9.9.9"),
1208 "default state should not invent skipped versions"
1209 );
1210 }
1211
1212 #[test]
1213 #[serial]
1214 fn test_update_info_should_show() {
1215 let info = UpdateInfo {
1216 latest_version: "1.0.0".into(),
1217 tag_name: "v1.0.0".into(),
1218 current_version: "0.9.0".into(),
1219 release_url: "https://example.com".into(),
1220 is_newer: true,
1221 is_skipped: false,
1222 };
1223 assert!(info.should_show());
1224
1225 let skipped = UpdateInfo {
1226 is_skipped: true,
1227 ..info.clone()
1228 };
1229 assert!(!skipped.should_show());
1230
1231 let not_newer = UpdateInfo {
1232 is_newer: false,
1233 ..info
1234 };
1235 assert!(!not_newer.should_show());
1236 }
1237
1238 #[test]
1243 #[serial]
1244 fn test_version_comparison_upgrade_scenarios() {
1245 let test_cases = vec![
1247 ("0.1.50", "0.1.52", true, "patch upgrade"),
1248 ("0.1.52", "0.2.0", true, "minor upgrade"),
1249 ("0.1.52", "1.0.0", true, "major upgrade"),
1250 ("0.1.52", "0.1.52", false, "same version"),
1251 ("0.1.52", "0.1.51", false, "downgrade"),
1252 ("0.1.52", "0.1.52-alpha", false, "prerelease is older"),
1253 (
1254 "0.1.52-alpha",
1255 "0.1.52",
1256 true,
1257 "stable is newer than prerelease",
1258 ),
1259 ];
1260
1261 for (current, latest, expected_newer, scenario) in test_cases {
1262 let current_ver = Version::parse(current).expect("valid current version");
1263 let latest_ver = Version::parse(latest).expect("valid latest version");
1264 let is_newer = latest_ver > current_ver;
1265 assert_eq!(
1266 is_newer, expected_newer,
1267 "scenario '{}': {} -> {} should be is_newer={}",
1268 scenario, current, latest, expected_newer
1269 );
1270 }
1271 }
1272
1273 #[test]
1274 #[serial]
1275 fn test_update_state_persistence_round_trip() {
1276 let temp_dir = tempfile::TempDir::new().unwrap();
1277 let state_file = temp_dir.path().join("update_state.json");
1278
1279 let mut state = UpdateState {
1281 last_check_ts: 1234567890,
1282 skipped_version: Some("0.1.50".to_string()),
1283 };
1284
1285 let json = serde_json::to_string_pretty(&state).unwrap();
1287 std::fs::write(&state_file, &json).unwrap();
1288
1289 let loaded: UpdateState =
1291 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1292
1293 assert_eq!(loaded.last_check_ts, 1234567890);
1294 assert_eq!(loaded.skipped_version, Some("0.1.50".to_string()));
1295 assert!(loaded.is_skipped("0.1.50"));
1296 assert!(!loaded.is_skipped("0.1.51"));
1297
1298 state.skip_version("0.1.51");
1300 state.mark_checked();
1301 let json = serde_json::to_string_pretty(&state).unwrap();
1302 std::fs::write(&state_file, &json).unwrap();
1303
1304 let loaded: UpdateState =
1305 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1306 assert!(loaded.is_skipped("0.1.51"));
1307 assert!(!loaded.is_skipped("0.1.50")); }
1309
1310 #[cfg(unix)]
1311 fn install_update_state_symlink(data_dir: &std::path::Path) -> (tempfile::TempDir, PathBuf) {
1312 use std::os::unix::fs::symlink;
1313
1314 let outside_dir = tempfile::TempDir::new().unwrap();
1315 let target_file = outside_dir.path().join("target-update-state.json");
1316 std::fs::write(&target_file, "untouched").unwrap();
1317 symlink(&target_file, data_dir.join("update_state.json")).unwrap();
1318 (outside_dir, target_file)
1319 }
1320
1321 #[cfg(unix)]
1322 fn assert_update_state_symlink_was_replaced(
1323 data_dir: &std::path::Path,
1324 target_file: &std::path::Path,
1325 expected_ts: i64,
1326 ) {
1327 let state_file = data_dir.join("update_state.json");
1328 assert_eq!(
1329 std::fs::read_to_string(target_file).unwrap(),
1330 "untouched",
1331 "update state persistence must not follow an existing symlink"
1332 );
1333 assert!(
1334 !std::fs::symlink_metadata(&state_file)
1335 .unwrap()
1336 .file_type()
1337 .is_symlink(),
1338 "state path should be replaced with a regular JSON file"
1339 );
1340
1341 let loaded: UpdateState =
1342 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1343 assert_eq!(loaded.last_check_ts, expected_ts);
1344 assert_eq!(loaded.skipped_version, Some("0.2.0".to_string()));
1345 }
1346
1347 #[cfg(unix)]
1348 #[test]
1349 #[serial]
1350 fn test_update_state_save_replaces_existing_symlink() {
1351 let temp_dir = tempfile::TempDir::new().unwrap();
1352 let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1353 unsafe {
1354 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1355 }
1356
1357 let state = UpdateState {
1358 last_check_ts: 42,
1359 skipped_version: Some("0.2.0".to_string()),
1360 };
1361 state.save().unwrap();
1362
1363 unsafe {
1364 std::env::remove_var("CASS_DATA_DIR");
1365 }
1366 assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 42);
1367 }
1368
1369 #[cfg(unix)]
1370 #[test]
1371 #[serial]
1372 fn test_update_state_save_async_replaces_existing_symlink() {
1373 let temp_dir = tempfile::TempDir::new().unwrap();
1374 let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1375 unsafe {
1376 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1377 }
1378
1379 let state = UpdateState {
1380 last_check_ts: 43,
1381 skipped_version: Some("0.2.0".to_string()),
1382 };
1383 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1384 .build()
1385 .expect("build test runtime");
1386 runtime.block_on(state.save_async()).unwrap();
1387
1388 unsafe {
1389 std::env::remove_var("CASS_DATA_DIR");
1390 }
1391 assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 43);
1392 }
1393
1394 #[test]
1395 #[serial]
1396 fn test_update_info_upgrade_workflow() {
1397 let info = UpdateInfo {
1401 latest_version: "0.2.0".into(),
1402 tag_name: "v0.2.0".into(),
1403 current_version: "0.1.52".into(),
1404 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0".into(),
1405 is_newer: true,
1406 is_skipped: false,
1407 };
1408 assert!(info.should_show(), "should show upgrade banner");
1409 assert!(info.is_newer, "should detect newer version");
1410
1411 let mut state = UpdateState::default();
1413 state.skip_version(&info.latest_version);
1414 assert!(state.is_skipped(&info.latest_version));
1415
1416 let info_after_skip = UpdateInfo {
1418 is_skipped: state.is_skipped(&info.latest_version),
1419 ..info.clone()
1420 };
1421 assert!(
1422 !info_after_skip.should_show(),
1423 "should not show banner for skipped version"
1424 );
1425
1426 state.clear_skip();
1428 let newer_info = UpdateInfo {
1429 latest_version: "0.3.0".into(),
1430 tag_name: "v0.3.0".into(),
1431 current_version: "0.1.52".into(),
1432 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0".into(),
1433 is_newer: true,
1434 is_skipped: false,
1435 };
1436 assert!(
1437 newer_info.should_show(),
1438 "should show banner for version newer than skipped"
1439 );
1440 }
1441
1442 #[test]
1443 #[serial]
1444 fn test_check_interval_respects_cadence() {
1445 let mut state = UpdateState::default();
1446
1447 assert!(state.should_check());
1449
1450 state.mark_checked();
1452 assert!(!state.should_check());
1453
1454 state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1456 assert!(!state.should_check());
1457
1458 state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1460 assert!(state.should_check());
1461 }
1462
1463 #[test]
1464 #[serial]
1465 fn test_github_repo_constant_is_valid() {
1466 assert!(GITHUB_REPO.contains('/'));
1468 let parts: Vec<&str> = GITHUB_REPO.split('/').collect();
1469 assert_eq!(parts.len(), 2, "should be owner/repo format");
1470 assert!(!parts[0].is_empty(), "owner should not be empty");
1471 assert!(!parts[1].is_empty(), "repo should not be empty");
1472 assert_eq!(parts[0], "Dicklesworthstone");
1473 assert_eq!(parts[1], "coding_agent_session_search");
1474 }
1475
1476 fn http_response(status: u16, body: &str) -> String {
1483 format!(
1484 "HTTP/1.1 {} {}\r\n\
1485 Content-Type: application/json\r\n\
1486 Content-Length: {}\r\n\
1487 Connection: close\r\n\
1488 \r\n\
1489 {}",
1490 status,
1491 match status {
1492 200 => "OK",
1493 404 => "Not Found",
1494 500 => "Internal Server Error",
1495 _ => "Unknown",
1496 },
1497 body.len(),
1498 body
1499 )
1500 }
1501
1502 fn start_test_server(
1504 response_body: &str,
1505 status: u16,
1506 ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1507 use std::io::{ErrorKind, Read, Write};
1508 use std::net::TcpListener;
1509 use std::sync::mpsc;
1510 use std::time::{Duration, Instant};
1511
1512 let listener = TcpListener::bind("127.0.0.1:0").expect("bind to ephemeral port");
1513 let addr = listener.local_addr().expect("get local addr");
1514 let _ = listener.set_nonblocking(true);
1515
1516 let response = http_response(status, response_body);
1517 let (ready_tx, ready_rx) = mpsc::channel();
1518
1519 let handle = std::thread::spawn(move || {
1520 let _ = ready_tx.send(());
1521 let deadline = Instant::now() + Duration::from_secs(2);
1522 let mut stream = loop {
1523 match listener.accept() {
1524 Ok((stream, _)) => break stream,
1525 Err(err)
1526 if err.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
1527 {
1528 std::thread::sleep(Duration::from_millis(5));
1529 }
1530 Err(_) => return,
1531 }
1532 };
1533
1534 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1535 let mut buf = [0u8; 4096];
1536 match stream.read(&mut buf) {
1537 Ok(_) => {}
1538 Err(err)
1539 if matches!(
1540 err.kind(),
1541 ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::UnexpectedEof
1542 ) => {}
1543 Err(_) => return,
1544 }
1545
1546 if stream.write_all(response.as_bytes()).is_ok() {
1547 let _ = stream.flush();
1548 std::thread::sleep(Duration::from_millis(25));
1549 }
1550 });
1551
1552 let _ = ready_rx.recv_timeout(std::time::Duration::from_secs(1));
1553
1554 (addr, handle)
1555 }
1556
1557 #[test]
1558 #[serial]
1559 fn integration_fetch_release_success() {
1560 let release_json = r#"{
1562 "tag_name": "v0.2.0",
1563 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1564 }"#;
1565
1566 let (addr, handle) = start_test_server(release_json, 200);
1567
1568 unsafe {
1572 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1573 }
1574
1575 let result = fetch_latest_release_blocking();
1577
1578 unsafe {
1580 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1581 }
1582
1583 handle.join().expect("server thread");
1584
1585 let release = result.expect("fetch should succeed");
1586 assert_eq!(release.tag_name, "v0.2.0");
1587 assert!(release.html_url.contains("v0.2.0"));
1588 }
1589
1590 #[test]
1591 #[serial]
1592 fn integration_fetch_release_404_error() {
1593 let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1594
1595 unsafe {
1596 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1597 }
1598
1599 let result = fetch_latest_release_blocking();
1600
1601 unsafe {
1602 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1603 }
1604
1605 handle.join().expect("server thread");
1606
1607 assert!(result.is_err(), "should return error for 404");
1608 let err = result.unwrap_err();
1609 assert!(
1610 err.to_string().contains("404") || err.to_string().contains("Not Found"),
1611 "error should mention 404: {}",
1612 err
1613 );
1614 }
1615
1616 #[test]
1617 #[serial]
1618 fn integration_fetch_release_malformed_json() {
1619 let (addr, handle) = start_test_server("this is not json", 200);
1620
1621 unsafe {
1622 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1623 }
1624
1625 let result = fetch_latest_release_blocking();
1626
1627 unsafe {
1628 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1629 }
1630
1631 handle.join().expect("server thread");
1632
1633 assert!(result.is_err(), "should return error for malformed JSON");
1634 }
1635
1636 #[test]
1637 #[serial]
1638 fn integration_fetch_release_missing_fields() {
1639 let incomplete_json = r#"{"some_other_field": "value"}"#;
1641
1642 let (addr, handle) = start_test_server(incomplete_json, 200);
1643
1644 unsafe {
1645 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1646 }
1647
1648 let result = fetch_latest_release_blocking();
1649
1650 unsafe {
1651 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1652 }
1653
1654 handle.join().expect("server thread");
1655
1656 assert!(result.is_err(), "should error on missing required fields");
1658 }
1659
1660 #[test]
1661 #[serial]
1662 fn integration_fetch_release_server_error() {
1663 let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1664
1665 unsafe {
1666 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1667 }
1668
1669 let result = fetch_latest_release_blocking();
1670
1671 unsafe {
1672 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1673 }
1674
1675 handle.join().expect("server thread");
1676
1677 assert!(result.is_err(), "should return error for 500");
1678 }
1679
1680 #[test]
1681 #[serial]
1682 fn integration_version_comparison_with_real_fetch() {
1683 let release_json = r#"{
1685 "tag_name": "v0.3.0",
1686 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1687 }"#;
1688
1689 let (addr, handle) = start_test_server(release_json, 200);
1690
1691 unsafe {
1692 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1693 }
1694
1695 let result = fetch_latest_release_blocking();
1696
1697 unsafe {
1698 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1699 }
1700
1701 handle.join().expect("server thread");
1702
1703 let release = result.expect("fetch should succeed");
1704
1705 let latest_str = release.tag_name.trim_start_matches('v');
1707 let latest = Version::parse(latest_str).expect("parse latest version");
1708 let current = Version::parse("0.1.50").expect("parse current version");
1709
1710 assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1711 }
1712
1713 #[test]
1714 #[serial]
1715 fn integration_prerelease_version_handling() {
1716 let release_json = r#"{
1718 "tag_name": "v0.2.0-beta.1",
1719 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1720 }"#;
1721
1722 let (addr, handle) = start_test_server(release_json, 200);
1723
1724 unsafe {
1725 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1726 }
1727
1728 let result = fetch_latest_release_blocking();
1729
1730 unsafe {
1731 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1732 }
1733
1734 handle.join().expect("server thread");
1735
1736 let release = result.expect("fetch should succeed");
1737 let latest_str = release.tag_name.trim_start_matches('v');
1738 let latest = Version::parse(latest_str).expect("parse prerelease version");
1739
1740 let stable = Version::parse("0.2.0").expect("parse stable version");
1742 assert!(
1743 latest < stable,
1744 "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1745 );
1746
1747 let older = Version::parse("0.1.50").expect("parse older version");
1749 assert!(
1750 latest > older,
1751 "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1752 );
1753 }
1754
1755 #[test]
1756 #[serial]
1757 fn integration_connection_refused_is_offline_friendly() {
1758 unsafe {
1760 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1761 }
1762
1763 let result = fetch_latest_release_blocking();
1764
1765 unsafe {
1766 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1767 }
1768
1769 assert!(
1771 result.is_err(),
1772 "should return error when server unreachable"
1773 );
1774 let err = result.unwrap_err();
1776 let err_chain = format!("{:?}", err).to_lowercase();
1777 assert!(
1778 err_chain.contains("connection")
1779 || err_chain.contains("connect")
1780 || err_chain.contains("refused")
1781 || err_chain.contains("fetch")
1782 || err_chain.contains("os error"),
1783 "should be a network/fetch error: {}",
1784 err_chain
1785 );
1786 }
1787
1788 #[test]
1789 #[serial]
1790 fn integration_failed_sync_check_does_not_throttle_future_checks() {
1791 let temp_dir = tempfile::TempDir::new().unwrap();
1792 let state_file = temp_dir.path().join("update_state.json");
1793 unsafe {
1794 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1795 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1796 std::env::remove_var("CASS_SKIP_UPDATE");
1797 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1798 std::env::remove_var("TUI_HEADLESS");
1799 std::env::remove_var("CI");
1800 }
1801
1802 let result = check_for_updates_sync("0.1.0");
1803 assert!(result.is_none(), "offline sync check should fail quietly");
1804
1805 assert!(
1806 !state_file.exists(),
1807 "failed sync checks must not persist cadence state"
1808 );
1809
1810 unsafe {
1811 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1812 std::env::remove_var("CASS_DATA_DIR");
1813 }
1814 }
1815
1816 #[test]
1817 #[serial]
1818 fn integration_failed_async_check_does_not_throttle_future_checks() {
1819 let temp_dir = tempfile::TempDir::new().unwrap();
1820 let state_file = temp_dir.path().join("update_state.json");
1821 unsafe {
1822 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1823 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1824 std::env::remove_var("CASS_SKIP_UPDATE");
1825 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1826 std::env::remove_var("TUI_HEADLESS");
1827 std::env::remove_var("CI");
1828 }
1829
1830 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1831 .build()
1832 .expect("build test runtime");
1833 let result = runtime.block_on(check_for_updates("0.1.0"));
1834 assert!(result.is_none(), "offline async check should fail quietly");
1835
1836 assert!(
1837 !state_file.exists(),
1838 "failed async checks must not persist cadence state"
1839 );
1840
1841 unsafe {
1842 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1843 std::env::remove_var("CASS_DATA_DIR");
1844 }
1845 }
1846
1847 #[cfg(unix)]
1848 #[test]
1849 #[serial]
1850 fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1851 use std::os::unix::fs::PermissionsExt;
1852
1853 let temp_dir = tempfile::TempDir::new().unwrap();
1854 let state_file = temp_dir.path().join("update_state.json");
1855 let state = UpdateState {
1856 last_check_ts: now_unix(),
1857 skipped_version: None,
1858 };
1859 std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1860
1861 let release_json = r#"{
1862 "tag_name": "v9.9.9",
1863 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1864 }"#;
1865 let (addr, handle) = start_test_server(release_json, 200);
1866
1867 let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1868 let file_metadata = std::fs::metadata(&state_file).unwrap();
1869 let dir_mode = dir_metadata.permissions().mode();
1870 let file_mode = file_metadata.permissions().mode();
1871
1872 let mut readonly_dir = dir_metadata.permissions();
1873 readonly_dir.set_mode(0o555);
1874 std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1875
1876 let mut readonly_file = file_metadata.permissions();
1877 readonly_file.set_mode(0o444);
1878 std::fs::set_permissions(&state_file, readonly_file).unwrap();
1879
1880 unsafe {
1881 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1882 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1883 std::env::remove_var("CASS_SKIP_UPDATE");
1884 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1885 std::env::remove_var("TUI_HEADLESS");
1886 std::env::remove_var("CI");
1887 }
1888
1889 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1890 .build()
1891 .expect("build test runtime");
1892 let result = runtime.block_on(force_check("0.1.0"));
1893
1894 let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1895 restore_file.set_mode(file_mode);
1896 std::fs::set_permissions(&state_file, restore_file).unwrap();
1897
1898 let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1899 restore_dir.set_mode(dir_mode);
1900 std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1901
1902 unsafe {
1903 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1904 std::env::remove_var("CASS_DATA_DIR");
1905 }
1906
1907 handle.join().expect("server thread");
1908
1909 let info = result.expect("force check should bypass cadence and succeed");
1910 assert_eq!(info.latest_version, "9.9.9");
1911 assert!(info.is_newer);
1912 }
1913
1914 #[test]
1915 #[serial]
1916 fn integration_blocking_fetch_release_success_v1() {
1917 let release_json = r#"{
1919 "tag_name": "v1.0.0",
1920 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1921 }"#;
1922
1923 let (addr, handle) = start_test_server(release_json, 200);
1924
1925 unsafe {
1926 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1927 }
1928
1929 let result = fetch_latest_release_blocking();
1930
1931 unsafe {
1932 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1933 }
1934
1935 handle.join().expect("server thread");
1936
1937 let release = result.expect("blocking fetch should succeed");
1938 assert_eq!(release.tag_name, "v1.0.0");
1939 }
1940
1941 #[test]
1942 #[serial]
1943 fn integration_blocking_fetch_release_403_error() {
1944 let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1945
1946 unsafe {
1947 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1948 }
1949
1950 let result = fetch_latest_release_blocking();
1951
1952 unsafe {
1953 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1954 }
1955
1956 handle.join().expect("server thread");
1957
1958 assert!(result.is_err(), "should error on 403");
1959 }
1960
1961 #[test]
1962 #[serial]
1963 fn integration_release_api_base_url_default() {
1964 unsafe {
1966 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1967 }
1968
1969 let url = release_api_base_url();
1970 assert!(
1971 url.contains("api.github.com"),
1972 "default should use GitHub API"
1973 );
1974 assert!(
1975 url.contains(GITHUB_REPO),
1976 "default should include repo path"
1977 );
1978 }
1979
1980 #[test]
1981 #[serial]
1982 fn integration_release_api_base_url_override() {
1983 let custom_url = "http://localhost:8080/api";
1984 unsafe {
1985 std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1986 }
1987
1988 let url = release_api_base_url();
1989
1990 unsafe {
1991 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1992 }
1993
1994 assert_eq!(url, custom_url, "should use custom URL from env var");
1995 }
1996
1997 #[test]
1998 #[serial]
1999 fn integration_http_timeout_is_reasonable() {
2000 const _: () = {
2001 assert!(
2003 HTTP_TIMEOUT_SECS <= 10,
2004 "HTTP timeout should be short to avoid blocking startup"
2005 );
2006 assert!(
2007 HTTP_TIMEOUT_SECS >= 3,
2008 "HTTP timeout should be long enough for slow networks"
2009 );
2010 };
2011 }
2012
2013 #[test]
2014 #[serial]
2015 fn integration_check_interval_is_reasonable() {
2016 const _: () = {
2017 assert!(
2019 CHECK_INTERVAL_SECS >= 3600,
2020 "should not check more than once per hour"
2021 );
2022 assert!(
2023 CHECK_INTERVAL_SECS <= 86400,
2024 "should check at least once per day"
2025 );
2026 };
2027 }
2028}