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::Shutdown;
1509 use std::net::TcpListener;
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
1518 let handle = std::thread::spawn(move || {
1519 let deadline = Instant::now() + Duration::from_secs(2);
1520 let mut stream = loop {
1521 match listener.accept() {
1522 Ok((stream, _)) => break stream,
1523 Err(err)
1524 if err.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
1525 {
1526 std::thread::sleep(Duration::from_millis(5));
1527 }
1528 Err(_) => return,
1529 }
1530 };
1531
1532 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1533 let mut buf = [0u8; 4096];
1534 match stream.read(&mut buf) {
1535 Ok(_) => {}
1536 Err(err)
1537 if matches!(
1538 err.kind(),
1539 ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::UnexpectedEof
1540 ) => {}
1541 Err(_) => return,
1542 }
1543
1544 if stream.write_all(response.as_bytes()).is_ok() {
1545 let _ = stream.flush();
1546 let _ = stream.shutdown(Shutdown::Both);
1547 }
1548 });
1549
1550 std::thread::sleep(std::time::Duration::from_millis(10));
1552
1553 (addr, handle)
1554 }
1555
1556 #[test]
1557 #[serial]
1558 fn integration_fetch_release_success() {
1559 let release_json = r#"{
1561 "tag_name": "v0.2.0",
1562 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1563 }"#;
1564
1565 let (addr, handle) = start_test_server(release_json, 200);
1566
1567 unsafe {
1571 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1572 }
1573
1574 let result = fetch_latest_release_blocking();
1576
1577 unsafe {
1579 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1580 }
1581
1582 handle.join().expect("server thread");
1583
1584 let release = result.expect("fetch should succeed");
1585 assert_eq!(release.tag_name, "v0.2.0");
1586 assert!(release.html_url.contains("v0.2.0"));
1587 }
1588
1589 #[test]
1590 #[serial]
1591 fn integration_fetch_release_404_error() {
1592 let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1593
1594 unsafe {
1595 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1596 }
1597
1598 let result = fetch_latest_release_blocking();
1599
1600 unsafe {
1601 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1602 }
1603
1604 handle.join().expect("server thread");
1605
1606 assert!(result.is_err(), "should return error for 404");
1607 let err = result.unwrap_err();
1608 assert!(
1609 err.to_string().contains("404") || err.to_string().contains("Not Found"),
1610 "error should mention 404: {}",
1611 err
1612 );
1613 }
1614
1615 #[test]
1616 #[serial]
1617 fn integration_fetch_release_malformed_json() {
1618 let (addr, handle) = start_test_server("this is not json", 200);
1619
1620 unsafe {
1621 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1622 }
1623
1624 let result = fetch_latest_release_blocking();
1625
1626 unsafe {
1627 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1628 }
1629
1630 handle.join().expect("server thread");
1631
1632 assert!(result.is_err(), "should return error for malformed JSON");
1633 }
1634
1635 #[test]
1636 #[serial]
1637 fn integration_fetch_release_missing_fields() {
1638 let incomplete_json = r#"{"some_other_field": "value"}"#;
1640
1641 let (addr, handle) = start_test_server(incomplete_json, 200);
1642
1643 unsafe {
1644 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1645 }
1646
1647 let result = fetch_latest_release_blocking();
1648
1649 unsafe {
1650 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1651 }
1652
1653 handle.join().expect("server thread");
1654
1655 assert!(result.is_err(), "should error on missing required fields");
1657 }
1658
1659 #[test]
1660 #[serial]
1661 fn integration_fetch_release_server_error() {
1662 let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1663
1664 unsafe {
1665 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1666 }
1667
1668 let result = fetch_latest_release_blocking();
1669
1670 unsafe {
1671 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1672 }
1673
1674 handle.join().expect("server thread");
1675
1676 assert!(result.is_err(), "should return error for 500");
1677 }
1678
1679 #[test]
1680 #[serial]
1681 fn integration_version_comparison_with_real_fetch() {
1682 let release_json = r#"{
1684 "tag_name": "v0.3.0",
1685 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1686 }"#;
1687
1688 let (addr, handle) = start_test_server(release_json, 200);
1689
1690 unsafe {
1691 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1692 }
1693
1694 let result = fetch_latest_release_blocking();
1695
1696 unsafe {
1697 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1698 }
1699
1700 handle.join().expect("server thread");
1701
1702 let release = result.expect("fetch should succeed");
1703
1704 let latest_str = release.tag_name.trim_start_matches('v');
1706 let latest = Version::parse(latest_str).expect("parse latest version");
1707 let current = Version::parse("0.1.50").expect("parse current version");
1708
1709 assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1710 }
1711
1712 #[test]
1713 #[serial]
1714 fn integration_prerelease_version_handling() {
1715 let release_json = r#"{
1717 "tag_name": "v0.2.0-beta.1",
1718 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1719 }"#;
1720
1721 let (addr, handle) = start_test_server(release_json, 200);
1722
1723 unsafe {
1724 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1725 }
1726
1727 let result = fetch_latest_release_blocking();
1728
1729 unsafe {
1730 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1731 }
1732
1733 handle.join().expect("server thread");
1734
1735 let release = result.expect("fetch should succeed");
1736 let latest_str = release.tag_name.trim_start_matches('v');
1737 let latest = Version::parse(latest_str).expect("parse prerelease version");
1738
1739 let stable = Version::parse("0.2.0").expect("parse stable version");
1741 assert!(
1742 latest < stable,
1743 "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1744 );
1745
1746 let older = Version::parse("0.1.50").expect("parse older version");
1748 assert!(
1749 latest > older,
1750 "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1751 );
1752 }
1753
1754 #[test]
1755 #[serial]
1756 fn integration_connection_refused_is_offline_friendly() {
1757 unsafe {
1759 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1760 }
1761
1762 let result = fetch_latest_release_blocking();
1763
1764 unsafe {
1765 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1766 }
1767
1768 assert!(
1770 result.is_err(),
1771 "should return error when server unreachable"
1772 );
1773 let err = result.unwrap_err();
1775 let err_chain = format!("{:?}", err).to_lowercase();
1776 assert!(
1777 err_chain.contains("connection")
1778 || err_chain.contains("connect")
1779 || err_chain.contains("refused")
1780 || err_chain.contains("fetch")
1781 || err_chain.contains("os error"),
1782 "should be a network/fetch error: {}",
1783 err_chain
1784 );
1785 }
1786
1787 #[test]
1788 #[serial]
1789 fn integration_failed_sync_check_does_not_throttle_future_checks() {
1790 let temp_dir = tempfile::TempDir::new().unwrap();
1791 let state_file = temp_dir.path().join("update_state.json");
1792 unsafe {
1793 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1794 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1795 std::env::remove_var("CASS_SKIP_UPDATE");
1796 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1797 std::env::remove_var("TUI_HEADLESS");
1798 std::env::remove_var("CI");
1799 }
1800
1801 let result = check_for_updates_sync("0.1.0");
1802 assert!(result.is_none(), "offline sync check should fail quietly");
1803
1804 assert!(
1805 !state_file.exists(),
1806 "failed sync checks must not persist cadence state"
1807 );
1808
1809 unsafe {
1810 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1811 std::env::remove_var("CASS_DATA_DIR");
1812 }
1813 }
1814
1815 #[test]
1816 #[serial]
1817 fn integration_failed_async_check_does_not_throttle_future_checks() {
1818 let temp_dir = tempfile::TempDir::new().unwrap();
1819 let state_file = temp_dir.path().join("update_state.json");
1820 unsafe {
1821 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1822 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1823 std::env::remove_var("CASS_SKIP_UPDATE");
1824 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1825 std::env::remove_var("TUI_HEADLESS");
1826 std::env::remove_var("CI");
1827 }
1828
1829 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1830 .build()
1831 .expect("build test runtime");
1832 let result = runtime.block_on(check_for_updates("0.1.0"));
1833 assert!(result.is_none(), "offline async check should fail quietly");
1834
1835 assert!(
1836 !state_file.exists(),
1837 "failed async checks must not persist cadence state"
1838 );
1839
1840 unsafe {
1841 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1842 std::env::remove_var("CASS_DATA_DIR");
1843 }
1844 }
1845
1846 #[cfg(unix)]
1847 #[test]
1848 #[serial]
1849 fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1850 use std::os::unix::fs::PermissionsExt;
1851
1852 let temp_dir = tempfile::TempDir::new().unwrap();
1853 let state_file = temp_dir.path().join("update_state.json");
1854 let state = UpdateState {
1855 last_check_ts: now_unix(),
1856 skipped_version: None,
1857 };
1858 std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1859
1860 let release_json = r#"{
1861 "tag_name": "v9.9.9",
1862 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1863 }"#;
1864 let (addr, handle) = start_test_server(release_json, 200);
1865
1866 let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1867 let file_metadata = std::fs::metadata(&state_file).unwrap();
1868 let dir_mode = dir_metadata.permissions().mode();
1869 let file_mode = file_metadata.permissions().mode();
1870
1871 let mut readonly_dir = dir_metadata.permissions();
1872 readonly_dir.set_mode(0o555);
1873 std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1874
1875 let mut readonly_file = file_metadata.permissions();
1876 readonly_file.set_mode(0o444);
1877 std::fs::set_permissions(&state_file, readonly_file).unwrap();
1878
1879 unsafe {
1880 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1881 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1882 std::env::remove_var("CASS_SKIP_UPDATE");
1883 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1884 std::env::remove_var("TUI_HEADLESS");
1885 std::env::remove_var("CI");
1886 }
1887
1888 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1889 .build()
1890 .expect("build test runtime");
1891 let result = runtime.block_on(force_check("0.1.0"));
1892
1893 let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1894 restore_file.set_mode(file_mode);
1895 std::fs::set_permissions(&state_file, restore_file).unwrap();
1896
1897 let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1898 restore_dir.set_mode(dir_mode);
1899 std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1900
1901 unsafe {
1902 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1903 std::env::remove_var("CASS_DATA_DIR");
1904 }
1905
1906 handle.join().expect("server thread");
1907
1908 let info = result.expect("force check should bypass cadence and succeed");
1909 assert_eq!(info.latest_version, "9.9.9");
1910 assert!(info.is_newer);
1911 }
1912
1913 #[test]
1914 #[serial]
1915 fn integration_blocking_fetch_release_success_v1() {
1916 let release_json = r#"{
1918 "tag_name": "v1.0.0",
1919 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1920 }"#;
1921
1922 let (addr, handle) = start_test_server(release_json, 200);
1923
1924 unsafe {
1925 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1926 }
1927
1928 let result = fetch_latest_release_blocking();
1929
1930 unsafe {
1931 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1932 }
1933
1934 handle.join().expect("server thread");
1935
1936 let release = result.expect("blocking fetch should succeed");
1937 assert_eq!(release.tag_name, "v1.0.0");
1938 }
1939
1940 #[test]
1941 #[serial]
1942 fn integration_blocking_fetch_release_403_error() {
1943 let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1944
1945 unsafe {
1946 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1947 }
1948
1949 let result = fetch_latest_release_blocking();
1950
1951 unsafe {
1952 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1953 }
1954
1955 handle.join().expect("server thread");
1956
1957 assert!(result.is_err(), "should error on 403");
1958 }
1959
1960 #[test]
1961 #[serial]
1962 fn integration_release_api_base_url_default() {
1963 unsafe {
1965 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1966 }
1967
1968 let url = release_api_base_url();
1969 assert!(
1970 url.contains("api.github.com"),
1971 "default should use GitHub API"
1972 );
1973 assert!(
1974 url.contains(GITHUB_REPO),
1975 "default should include repo path"
1976 );
1977 }
1978
1979 #[test]
1980 #[serial]
1981 fn integration_release_api_base_url_override() {
1982 let custom_url = "http://localhost:8080/api";
1983 unsafe {
1984 std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1985 }
1986
1987 let url = release_api_base_url();
1988
1989 unsafe {
1990 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1991 }
1992
1993 assert_eq!(url, custom_url, "should use custom URL from env var");
1994 }
1995
1996 #[test]
1997 #[serial]
1998 fn integration_http_timeout_is_reasonable() {
1999 const _: () = {
2000 assert!(
2002 HTTP_TIMEOUT_SECS <= 10,
2003 "HTTP timeout should be short to avoid blocking startup"
2004 );
2005 assert!(
2006 HTTP_TIMEOUT_SECS >= 3,
2007 "HTTP timeout should be long enough for slow networks"
2008 );
2009 };
2010 }
2011
2012 #[test]
2013 #[serial]
2014 fn integration_check_interval_is_reasonable() {
2015 const _: () = {
2016 assert!(
2018 CHECK_INTERVAL_SECS >= 3600,
2019 "should not check more than once per hour"
2020 );
2021 assert!(
2022 CHECK_INTERVAL_SECS <= 86400,
2023 "should check at least once per day"
2024 );
2025 };
2026 }
2027}