1use anyhow::{Context, Result};
10use semver::Version;
11use serde::{Deserialize, Serialize};
12use std::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 std::fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
92 Ok(())
93 }
94
95 pub async fn save_async(&self) -> Result<()> {
97 let path = state_path();
98 if let Some(parent) = path.parent() {
99 asupersync::fs::create_dir_all(parent)
100 .await
101 .with_context(|| format!("creating update state directory {}", parent.display()))?;
102 }
103 let json = serde_json::to_string_pretty(self).context("serializing update state")?;
104 asupersync::fs::write(&path, json)
105 .await
106 .with_context(|| format!("writing {}", path.display()))?;
107 Ok(())
108 }
109
110 pub fn should_check(&self) -> bool {
112 let now = now_unix();
113 if self.last_check_ts <= 0 || self.last_check_ts > now {
114 return true;
115 }
116 now.saturating_sub(self.last_check_ts) >= CHECK_INTERVAL_SECS as i64
117 }
118
119 pub fn mark_checked(&mut self) {
121 self.last_check_ts = now_unix();
122 }
123
124 pub fn skip_version(&mut self, version: &str) {
126 self.skipped_version = Some(version.to_string());
127 }
128
129 pub fn is_skipped(&self, version: &str) -> bool {
131 self.skipped_version.as_deref() == Some(version)
132 }
133
134 pub fn clear_skip(&mut self) {
136 self.skipped_version = None;
137 }
138}
139
140#[derive(Debug, Clone)]
142pub struct UpdateInfo {
143 pub latest_version: String,
145 pub tag_name: String,
147 pub current_version: String,
149 pub release_url: String,
151 pub is_newer: bool,
153 pub is_skipped: bool,
155}
156
157impl UpdateInfo {
158 pub fn should_show(&self) -> bool {
160 self.is_newer && !self.is_skipped
161 }
162}
163
164#[derive(Debug, Deserialize)]
166struct GitHubRelease {
167 tag_name: String,
168 html_url: String,
169}
170
171pub async fn check_for_updates(current_version: &str) -> Option<UpdateInfo> {
178 check_for_updates_async_impl(current_version, false).await
179}
180
181async fn check_for_updates_async_impl(current_version: &str, force: bool) -> Option<UpdateInfo> {
182 if updates_disabled() {
184 return None;
185 }
186
187 let mut state = UpdateState::load_async().await;
188
189 if !force && !state.should_check() {
191 debug!("update check: skipping, checked recently");
192 return None;
193 }
194
195 let release = match fetch_latest_release().await {
196 Ok(r) => r,
197 Err(e) => {
198 debug!("update check: fetch failed (offline?): {e}");
199 return None;
200 }
201 };
202
203 let info = build_update_info(current_version, release, &state)?;
204
205 state.mark_checked();
208 if let Err(e) = state.save_async().await {
209 warn!("update check: failed to save state: {e}");
210 }
211
212 Some(info)
213}
214
215pub async fn force_check(current_version: &str) -> Option<UpdateInfo> {
217 check_for_updates_async_impl(current_version, true).await
218}
219
220pub fn skip_version(version: &str) -> Result<()> {
222 let mut state = UpdateState::load();
223 state.skip_version(version);
224 state.save()
225}
226
227pub fn open_in_browser(url: &str) -> std::io::Result<()> {
229 validate_browser_url(url)?;
230
231 #[cfg(target_os = "windows")]
232 {
233 std::process::Command::new("rundll32")
234 .args(["url.dll,FileProtocolHandler", url])
235 .spawn()?;
236 }
237 #[cfg(target_os = "macos")]
238 {
239 std::process::Command::new("open").arg(url).spawn()?;
240 }
241 #[cfg(target_os = "linux")]
242 {
243 std::process::Command::new("xdg-open").arg(url).spawn()?;
244 }
245 Ok(())
246}
247
248fn validate_browser_url(url: &str) -> std::io::Result<()> {
249 if is_browser_url(url) {
250 Ok(())
251 } else {
252 Err(std::io::Error::new(
253 std::io::ErrorKind::InvalidInput,
254 "release notes URL must be an absolute http(s) URL",
255 ))
256 }
257}
258
259fn is_browser_url(url: &str) -> bool {
260 let Ok(parsed) = url::Url::parse(url) else {
261 return false;
262 };
263 matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some()
264}
265
266fn is_trusted_release_notes_url(url: &str) -> bool {
267 let Ok(parsed) = url::Url::parse(url) else {
268 return false;
269 };
270 if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") {
271 return false;
272 }
273
274 let Some((expected_owner, expected_repo)) = GITHUB_REPO.split_once('/') else {
275 return false;
276 };
277 let Some(mut path_segments) = parsed.path_segments() else {
278 return false;
279 };
280 let Some(owner) = path_segments.next() else {
281 return false;
282 };
283 let Some(repo) = path_segments.next() else {
284 return false;
285 };
286 let Some(section) = path_segments.next() else {
287 return false;
288 };
289
290 owner.eq_ignore_ascii_case(expected_owner)
291 && repo.eq_ignore_ascii_case(expected_repo)
292 && section == "releases"
293}
294
295fn release_asset_url(version: &str, asset: &str) -> String {
296 format!("https://github.com/{GITHUB_REPO}/releases/download/{version}/{asset}")
297}
298
299fn parse_update_tag(tag: &str) -> Option<(&str, Version)> {
300 if tag.trim() != tag {
301 return None;
302 }
303
304 let version = tag.strip_prefix('v').unwrap_or(tag);
305 let parsed = Version::parse(version).ok()?;
306 Some((version, parsed))
307}
308
309fn is_valid_update_tag(tag: &str) -> bool {
310 parse_update_tag(tag).is_some()
311}
312
313#[cfg(any(test, target_os = "macos", target_os = "linux"))]
314fn unix_self_update_script() -> &'static str {
315 r#"
316set -euo pipefail
317
318tmp="$(mktemp -d "${TMPDIR:-/tmp}/cass-self-update.XXXXXX")"
319cleanup() {
320 rm -r "$tmp" 2>/dev/null || true
321}
322trap cleanup EXIT
323
324script="$tmp/install.sh"
325sums="$tmp/SHA256SUMS.txt"
326curl -fsSL "$1" -o "$script"
327expected=""
328for checksums_url in "$2" "$4"; do
329 [ -n "$checksums_url" ] || continue
330 if ! curl -fsSL "$checksums_url" -o "$sums"; then
331 continue
332 fi
333 candidate="$(awk '$2 == "install.sh" { print $1; exit }' "$sums")"
334 if printf '%s' "$candidate" | grep -Eq '^[0-9a-fA-F]{64}$'; then
335 expected="$candidate"
336 break
337 fi
338done
339if ! printf '%s' "$expected" | grep -Eq '^[0-9a-fA-F]{64}$'; then
340 echo "install.sh checksum missing from release checksum manifests" >&2
341 exit 1
342fi
343expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')"
344
345if command -v sha256sum >/dev/null 2>&1; then
346 printf '%s %s\n' "$expected_lc" "$script" | sha256sum -c -
347elif command -v shasum >/dev/null 2>&1; then
348 actual="$(shasum -a 256 "$script" | awk '{ print $1 }' | tr '[:upper:]' '[:lower:]')"
349 if [ "$actual" != "$expected_lc" ]; then
350 echo "install.sh checksum mismatch" >&2
351 exit 1
352 fi
353elif command -v openssl >/dev/null 2>&1; then
354 actual="$(openssl dgst -sha256 "$script" | awk '{ print $NF }' | tr '[:upper:]' '[:lower:]')"
355 if [ "$actual" != "$expected_lc" ]; then
356 echo "install.sh checksum mismatch" >&2
357 exit 1
358 fi
359else
360 echo "No SHA-256 verification tool found" >&2
361 exit 1
362fi
363
364exec bash "$script" --easy-mode --verify --version "$3"
365"#
366}
367
368#[cfg(any(test, target_os = "windows"))]
369fn windows_self_update_script() -> &'static str {
370 r#"
371$InstallUrl = $args[0]
372$ChecksumsUrl = $args[1]
373$Version = $args[2]
374$Temp = Join-Path ([IO.Path]::GetTempPath()) ("cass-self-update-" + [guid]::NewGuid().ToString("N"))
375New-Item -ItemType Directory -Path $Temp -Force | Out-Null
376try {
377 $Script = Join-Path $Temp "install.ps1"
378 $Sums = Join-Path $Temp "SHA256SUMS.txt"
379 Invoke-WebRequest -Uri $InstallUrl -OutFile $Script -UseBasicParsing
380
381 $Expected = $null
382 foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3])) {
383 if (-not $ChecksumsCandidateUrl) {
384 continue
385 }
386 try {
387 Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums -UseBasicParsing
388 } catch {
389 continue
390 }
391
392 foreach ($Line in Get-Content -LiteralPath $Sums) {
393 $Parts = $Line.Trim() -split '\s+', 2
394 if ($Parts.Count -ge 2 -and $Parts[1] -eq "install.ps1" -and $Parts[0] -match '^[0-9a-fA-F]{64}$') {
395 $Expected = $Parts[0].ToLowerInvariant()
396 break
397 }
398 }
399 if ($Expected) {
400 break
401 }
402 }
403 if (-not $Expected) {
404 Write-Error "install.ps1 checksum missing from release checksum manifests"
405 exit 1
406 }
407
408 $Actual = (Get-FileHash -LiteralPath $Script -Algorithm SHA256).Hash.ToLowerInvariant()
409 if ($Actual -ne $Expected) {
410 Write-Error "install.ps1 checksum mismatch"
411 exit 1
412 }
413
414 & $Script -EasyMode -Verify -Version $Version
415 exit $LASTEXITCODE
416} finally {
417 Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue
418}
419"#
420}
421
422pub fn run_self_update(version: &str) -> ! {
426 if !is_valid_update_tag(version) {
429 eprintln!("Invalid version string: {}", version);
430 std::process::exit(1);
431 }
432
433 #[cfg(any(target_os = "macos", target_os = "linux"))]
434 {
435 use std::os::unix::process::CommandExt;
436 let install_url = release_asset_url(version, UNIX_INSTALL_ASSET);
437 let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
438 let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
439 let err = std::process::Command::new("bash")
441 .args([
442 "-c",
443 unix_self_update_script(),
444 "cass-updater",
445 &install_url,
446 &checksums_url,
447 version,
448 &checksums_alt_url,
449 ])
450 .exec();
451 eprintln!("Failed to run installer: {}", err);
453 std::process::exit(1);
454 }
455
456 #[cfg(target_os = "windows")]
457 {
458 let install_url = release_asset_url(version, WINDOWS_INSTALL_ASSET);
459 let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
460 let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
461 let status = std::process::Command::new("powershell")
463 .args([
464 "-ExecutionPolicy",
465 "Bypass",
466 "-NoProfile",
467 "-Command",
468 windows_self_update_script(),
469 &install_url,
470 &checksums_url,
471 version,
472 &checksums_alt_url,
473 ])
474 .status();
475 match status {
476 Ok(s) => std::process::exit(s.code().unwrap_or(0)),
477 Err(e) => {
478 eprintln!("Failed to run installer: {}", e);
479 std::process::exit(1);
480 }
481 }
482 }
483}
484
485fn release_api_base_url() -> String {
500 let default = || format!("https://api.github.com/repos/{GITHUB_REPO}");
501 let Ok(override_url) = dotenvy::var("CASS_UPDATE_API_BASE_URL") else {
502 return default();
503 };
504 if is_allowed_update_api_url(&override_url) {
505 override_url
506 } else {
507 eprintln!(
508 "warning: CASS_UPDATE_API_BASE_URL={override_url:?} ignored \
509 (only GitHub HTTPS URLs or http://localhost/127.0.0.1 test endpoints allowed). \
510 Falling back to the default GitHub release API."
511 );
512 default()
513 }
514}
515
516fn is_allowed_update_api_url(url: &str) -> bool {
521 let Ok(parsed) = url::Url::parse(url) else {
522 return false;
523 };
524 let Some(host) = parsed.host_str() else {
525 return false;
526 };
527
528 match parsed.scheme() {
529 "https" => matches!(host, "api.github.com" | "github.com"),
530 "http" => matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]"),
531 _ => false,
532 }
533}
534
535fn state_path() -> PathBuf {
537 crate::default_data_dir().join("update_state.json")
538}
539
540fn legacy_state_path() -> PathBuf {
541 directories::ProjectDirs::from("com", "coding-agent-search", "coding-agent-search").map_or_else(
542 || PathBuf::from("update_state.json"),
543 |dirs| dirs.data_dir().join("update_state.json"),
544 )
545}
546
547fn now_unix() -> i64 {
549 i64::try_from(
550 SystemTime::now()
551 .duration_since(UNIX_EPOCH)
552 .unwrap_or_default()
553 .as_secs(),
554 )
555 .unwrap_or(i64::MAX)
556}
557
558pub fn check_for_updates_sync(current_version: &str) -> Option<UpdateInfo> {
565 if updates_disabled() {
566 return None;
567 }
568
569 let mut state = UpdateState::load();
570
571 if !state.should_check() {
573 debug!("update check: skipping, checked recently");
574 return None;
575 }
576
577 let release = match fetch_latest_release_blocking() {
579 Ok(r) => r,
580 Err(e) => {
581 debug!("update check: fetch failed (offline?): {e}");
582 return None;
583 }
584 };
585
586 let info = build_update_info(current_version, release, &state)?;
587
588 state.mark_checked();
591 if let Err(e) = state.save() {
592 warn!("update check: failed to save state: {e}");
593 }
594
595 Some(info)
596}
597
598fn build_update_info(
599 current_version: &str,
600 release: GitHubRelease,
601 state: &UpdateState,
602) -> Option<UpdateInfo> {
603 let GitHubRelease { tag_name, html_url } = release;
604 if !is_trusted_release_notes_url(&html_url) {
605 debug!("update check: untrusted release notes URL '{}'", html_url);
606 return None;
607 }
608
609 let (latest_version, latest) = match parse_update_tag(&tag_name) {
610 Some((version, parsed)) => (version.to_string(), parsed),
611 None => {
612 debug!("update check: invalid version tag '{}'", tag_name);
613 return None;
614 }
615 };
616
617 let current = match Version::parse(current_version) {
618 Ok(v) => v,
619 Err(e) => {
620 debug!("update check: invalid current version '{current_version}': {e}");
621 return None;
622 }
623 };
624 let is_skipped = state.is_skipped(&latest_version);
625
626 Some(UpdateInfo {
627 latest_version,
628 tag_name,
629 current_version: current_version.to_string(),
630 release_url: html_url,
631 is_newer: latest > current,
632 is_skipped,
633 })
634}
635
636async fn fetch_latest_release() -> Result<GitHubRelease> {
638 if let Some(cx) = asupersync::Cx::current() {
639 return fetch_latest_release_with_cx(&cx).await;
640 }
641
642 let handle = asupersync::runtime::Runtime::current_handle()
643 .context("update check requires an active asupersync runtime")?;
644 let (tx, rx) = std::sync::mpsc::channel();
645
646 handle
647 .try_spawn_with_cx(move |cx| async move {
648 let _ = tx.send(fetch_latest_release_with_cx(&cx).await);
649 })
650 .context("spawning update check task")?;
651
652 loop {
653 match rx.try_recv() {
654 Ok(result) => return result,
655 Err(TryRecvError::Empty) => asupersync::runtime::yield_now().await,
656 Err(TryRecvError::Disconnected) => {
657 anyhow::bail!("update check task exited before returning a result");
658 }
659 }
660 }
661}
662
663async fn fetch_latest_release_with_cx(cx: &asupersync::Cx) -> Result<GitHubRelease> {
664 let url = format!("{}/releases/latest", release_api_base_url());
665 let client = asupersync::http::h1::HttpClient::builder()
666 .user_agent(concat!("cass/", env!("CARGO_PKG_VERSION")))
667 .build();
668 let response = asupersync::time::timeout(
669 cx.now(),
670 Duration::from_secs(HTTP_TIMEOUT_SECS),
671 client.request(
672 cx,
673 asupersync::http::h1::Method::Get,
674 &url,
675 vec![(
676 "Accept".to_string(),
677 "application/vnd.github.v3+json".to_string(),
678 )],
679 Vec::new(),
680 ),
681 )
682 .await
683 .map_err(|e| anyhow::anyhow!("timed out fetching release: {e}"))?
684 .context("fetching release")?;
685
686 if !response.is_success() {
687 anyhow::bail!("GitHub API returned {}", response.status);
688 }
689
690 response
691 .json::<GitHubRelease>()
692 .context("parsing release JSON")
693}
694
695fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
697 asupersync::runtime::RuntimeBuilder::current_thread()
698 .build()
699 .context("building update-check runtime")?
700 .block_on(fetch_latest_release())
701}
702
703pub fn spawn_update_check(
706 current_version: String,
707) -> std::sync::mpsc::Receiver<Option<UpdateInfo>> {
708 let (tx, rx) = std::sync::mpsc::channel();
709 if updates_disabled() {
710 let _ = tx.send(None);
711 return rx;
712 }
713 std::thread::spawn(move || {
714 let result = check_for_updates_sync(¤t_version);
715 let _ = tx.send(result);
716 });
717 rx
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use serial_test::serial;
724
725 #[test]
726 fn test_release_asset_url_uses_immutable_release_downloads() {
727 assert_eq!(
728 release_asset_url("v1.2.3", UNIX_INSTALL_ASSET),
729 format!(
730 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_ASSET}"
731 )
732 );
733 assert_eq!(
734 release_asset_url("v1.2.3", CHECKSUMS_ASSET),
735 format!("https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET}")
736 );
737 assert_eq!(
738 release_asset_url("v1.2.3", CHECKSUMS_ASSET_ALT),
739 format!(
740 "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET_ALT}"
741 )
742 );
743 }
744
745 #[test]
746 fn test_update_tag_validation_accepts_semver_release_tags() {
747 for tag in [
748 "1.2.3",
749 "v1.2.3",
750 "1.2.3-alpha.1",
751 "v1.2.3-alpha.1",
752 "1.2.3+build.5",
753 "v1.2.3-alpha.1+build.5",
754 ] {
755 assert!(
756 is_valid_update_tag(tag),
757 "expected update tag {tag:?} to be accepted"
758 );
759 }
760 }
761
762 #[test]
763 fn test_update_tag_validation_rejects_non_semver_or_pathlike_tags() {
764 for tag in [
765 "",
766 "v",
767 "..",
768 "v..",
769 "latest",
770 "vlatest",
771 "vv1.2.3",
772 "1.2",
773 "1",
774 "1.2.3/",
775 "1.2.3/../../main",
776 " v1.2.3",
777 "v1.2.3 ",
778 ] {
779 assert!(
780 !is_valid_update_tag(tag),
781 "expected update tag {tag:?} to be rejected"
782 );
783 }
784 }
785
786 #[test]
787 fn test_unix_self_update_verifies_installer_script_before_running() {
788 let script = unix_self_update_script();
789 assert!(script.contains(CHECKSUMS_ASSET));
790 assert!(
791 script.contains(r#"for checksums_url in "$2" "$4"; do"#),
792 "Unix self-update should try both checksum manifest URLs"
793 );
794 assert!(script.contains(r#"expected="$candidate""#));
795 assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
796 assert!(script.contains("sha256sum -c -"));
797 assert!(script.contains("shasum -a 256"));
798 assert!(script.contains("openssl dgst -sha256"));
799 assert!(script.contains(r#"exec bash "$script" --easy-mode --verify --version "$3""#));
800 }
801
802 #[test]
803 fn test_windows_self_update_verifies_installer_script_before_running() {
804 let script = windows_self_update_script();
805 assert!(script.contains(CHECKSUMS_ASSET));
806 assert!(
807 script.contains("foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3]))"),
808 "Windows self-update should try both checksum manifest URLs"
809 );
810 assert!(script.contains("Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums"));
811 assert!(script.contains("if ($Expected)"));
812 assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
813 assert!(script.contains("Get-FileHash"));
814 assert!(script.contains("-EasyMode -Verify -Version $Version"));
815 assert!(script.contains("Remove-Item -LiteralPath $Temp"));
816 }
817
818 #[test]
819 fn test_browser_url_validation_allows_absolute_web_urls() {
820 assert!(is_browser_url(
821 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3"
822 ));
823 assert!(is_browser_url("http://localhost:8080/releases/v1.2.3"));
824 assert!(is_browser_url(
825 "https://github.com/releases/tag/v1.2.3?asset=install.sh&download=1"
826 ));
827 }
828
829 #[test]
830 fn test_browser_url_validation_rejects_non_web_or_relative_urls() {
831 assert!(!is_browser_url(""));
832 assert!(!is_browser_url("github.com/releases/tag/v1.2.3"));
833 assert!(!is_browser_url("file:///etc/passwd"));
834 assert!(!is_browser_url("javascript:alert(1)"));
835 assert!(!is_browser_url("data:text/html,<script>alert(1)</script>"));
836 }
837
838 #[test]
839 fn test_release_info_rejects_untrusted_release_notes_urls() {
840 let state = UpdateState::default();
841 let release = GitHubRelease {
842 tag_name: "v9.9.9".to_string(),
843 html_url: "https://attacker.example/releases/tag/v9.9.9".to_string(),
844 };
845 assert!(
846 build_update_info("1.0.0", release, &state).is_none(),
847 "release metadata should not surface non-GitHub release notes URLs"
848 );
849
850 let release = GitHubRelease {
851 tag_name: "v9.9.9".to_string(),
852 html_url: "file:///tmp/release-notes.html".to_string(),
853 };
854 assert!(
855 build_update_info("1.0.0", release, &state).is_none(),
856 "release metadata should not surface non-web URLs"
857 );
858
859 let release = GitHubRelease {
860 tag_name: "v9.9.9".to_string(),
861 html_url: "https://github.com/other/project/releases/tag/v9.9.9".to_string(),
862 };
863 assert!(
864 build_update_info("1.0.0", release, &state).is_none(),
865 "release metadata should not surface unrelated GitHub release notes URLs"
866 );
867 }
868
869 #[test]
870 fn test_release_info_rejects_non_semver_release_tags() {
871 let state = UpdateState::default();
872 for tag in ["latest", "..", "vv9.9.9"] {
873 let release = GitHubRelease {
874 tag_name: tag.to_string(),
875 html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/{tag}"),
876 };
877 assert!(
878 build_update_info("1.0.0", release, &state).is_none(),
879 "release metadata should not surface non-SemVer tag {tag:?}"
880 );
881 }
882 }
883
884 #[test]
890 fn test_is_allowed_update_api_url_allows_trusted_https_hosts() {
891 assert!(is_allowed_update_api_url(
892 "https://api.github.com/repos/foo"
893 ));
894 assert!(is_allowed_update_api_url(
895 "https://api.github.com/repos/bar/baz"
896 ));
897 assert!(is_allowed_update_api_url(
898 "https://github.com/Dicklesworthstone/coding_agent_session_search/releases"
899 ));
900 }
901
902 #[test]
903 fn test_is_allowed_update_api_url_rejects_untrusted_https_hosts() {
904 assert!(!is_allowed_update_api_url("https://attacker.example.com"));
905 assert!(!is_allowed_update_api_url("https://example.internal"));
906 assert!(!is_allowed_update_api_url(
907 "https://api.github.com.attacker.example/repos/foo"
908 ));
909 assert!(!is_allowed_update_api_url(
910 "https://github.com.attacker.example/releases"
911 ));
912 }
913
914 #[test]
915 fn test_is_allowed_update_api_url_allows_http_loopback_only() {
916 assert!(is_allowed_update_api_url("http://127.0.0.1:8080"));
917 assert!(is_allowed_update_api_url("http://127.0.0.1:45123/api"));
918 assert!(is_allowed_update_api_url("http://localhost:1234"));
919 assert!(is_allowed_update_api_url("http://[::1]:8080"));
920 }
921
922 #[test]
923 fn test_is_allowed_update_api_url_rejects_non_loopback_http() {
924 assert!(!is_allowed_update_api_url("http://attacker.com"));
925 assert!(!is_allowed_update_api_url("http://example.com/api"));
926 assert!(!is_allowed_update_api_url("http://127.0.0.1.attacker.com"));
929 assert!(!is_allowed_update_api_url("http://localhost.attacker.com"));
930 }
931
932 #[test]
933 fn test_is_allowed_update_api_url_rejects_other_schemes() {
934 assert!(!is_allowed_update_api_url("ftp://api.github.com"));
935 assert!(!is_allowed_update_api_url("file:///etc/passwd"));
936 assert!(!is_allowed_update_api_url("gopher://example.com"));
937 assert!(!is_allowed_update_api_url(""));
938 assert!(!is_allowed_update_api_url("api.github.com"));
939 assert!(!is_allowed_update_api_url("https://"));
942 assert!(!is_allowed_update_api_url("https:///path"));
943 }
944
945 #[test]
946 #[serial]
947 fn test_state_should_check() {
948 let mut state = UpdateState::default();
949 assert!(state.should_check()); state.mark_checked();
952 assert!(!state.should_check()); state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
956 assert!(state.should_check()); state.last_check_ts = now_unix() + CHECK_INTERVAL_SECS as i64;
961 assert!(state.should_check());
962 }
963
964 #[test]
965 #[serial]
966 fn test_skip_version() {
967 let mut state = UpdateState::default();
968 assert!(!state.is_skipped("1.0.0"));
969
970 state.skip_version("1.0.0");
971 assert!(state.is_skipped("1.0.0"));
972 assert!(!state.is_skipped("1.0.1"));
973
974 state.clear_skip();
975 assert!(!state.is_skipped("1.0.0"));
976 }
977
978 #[test]
979 #[serial]
980 fn update_check_state_remains_functional_without_session_dismiss_stub() {
981 let state = UpdateState::default();
982 assert!(
983 state.should_check(),
984 "fresh state should still trigger checks"
985 );
986 assert!(
987 !state.is_skipped("9.9.9"),
988 "default state should not invent skipped versions"
989 );
990 }
991
992 #[test]
993 #[serial]
994 fn test_update_info_should_show() {
995 let info = UpdateInfo {
996 latest_version: "1.0.0".into(),
997 tag_name: "v1.0.0".into(),
998 current_version: "0.9.0".into(),
999 release_url: "https://example.com".into(),
1000 is_newer: true,
1001 is_skipped: false,
1002 };
1003 assert!(info.should_show());
1004
1005 let skipped = UpdateInfo {
1006 is_skipped: true,
1007 ..info.clone()
1008 };
1009 assert!(!skipped.should_show());
1010
1011 let not_newer = UpdateInfo {
1012 is_newer: false,
1013 ..info
1014 };
1015 assert!(!not_newer.should_show());
1016 }
1017
1018 #[test]
1023 #[serial]
1024 fn test_version_comparison_upgrade_scenarios() {
1025 let test_cases = vec![
1027 ("0.1.50", "0.1.52", true, "patch upgrade"),
1028 ("0.1.52", "0.2.0", true, "minor upgrade"),
1029 ("0.1.52", "1.0.0", true, "major upgrade"),
1030 ("0.1.52", "0.1.52", false, "same version"),
1031 ("0.1.52", "0.1.51", false, "downgrade"),
1032 ("0.1.52", "0.1.52-alpha", false, "prerelease is older"),
1033 (
1034 "0.1.52-alpha",
1035 "0.1.52",
1036 true,
1037 "stable is newer than prerelease",
1038 ),
1039 ];
1040
1041 for (current, latest, expected_newer, scenario) in test_cases {
1042 let current_ver = Version::parse(current).expect("valid current version");
1043 let latest_ver = Version::parse(latest).expect("valid latest version");
1044 let is_newer = latest_ver > current_ver;
1045 assert_eq!(
1046 is_newer, expected_newer,
1047 "scenario '{}': {} -> {} should be is_newer={}",
1048 scenario, current, latest, expected_newer
1049 );
1050 }
1051 }
1052
1053 #[test]
1054 #[serial]
1055 fn test_update_state_persistence_round_trip() {
1056 let temp_dir = tempfile::TempDir::new().unwrap();
1057 let state_file = temp_dir.path().join("update_state.json");
1058
1059 let mut state = UpdateState {
1061 last_check_ts: 1234567890,
1062 skipped_version: Some("0.1.50".to_string()),
1063 };
1064
1065 let json = serde_json::to_string_pretty(&state).unwrap();
1067 std::fs::write(&state_file, &json).unwrap();
1068
1069 let loaded: UpdateState =
1071 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1072
1073 assert_eq!(loaded.last_check_ts, 1234567890);
1074 assert_eq!(loaded.skipped_version, Some("0.1.50".to_string()));
1075 assert!(loaded.is_skipped("0.1.50"));
1076 assert!(!loaded.is_skipped("0.1.51"));
1077
1078 state.skip_version("0.1.51");
1080 state.mark_checked();
1081 let json = serde_json::to_string_pretty(&state).unwrap();
1082 std::fs::write(&state_file, &json).unwrap();
1083
1084 let loaded: UpdateState =
1085 serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1086 assert!(loaded.is_skipped("0.1.51"));
1087 assert!(!loaded.is_skipped("0.1.50")); }
1089
1090 #[test]
1091 #[serial]
1092 fn test_update_info_upgrade_workflow() {
1093 let info = UpdateInfo {
1097 latest_version: "0.2.0".into(),
1098 tag_name: "v0.2.0".into(),
1099 current_version: "0.1.52".into(),
1100 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0".into(),
1101 is_newer: true,
1102 is_skipped: false,
1103 };
1104 assert!(info.should_show(), "should show upgrade banner");
1105 assert!(info.is_newer, "should detect newer version");
1106
1107 let mut state = UpdateState::default();
1109 state.skip_version(&info.latest_version);
1110 assert!(state.is_skipped(&info.latest_version));
1111
1112 let info_after_skip = UpdateInfo {
1114 is_skipped: state.is_skipped(&info.latest_version),
1115 ..info.clone()
1116 };
1117 assert!(
1118 !info_after_skip.should_show(),
1119 "should not show banner for skipped version"
1120 );
1121
1122 state.clear_skip();
1124 let newer_info = UpdateInfo {
1125 latest_version: "0.3.0".into(),
1126 tag_name: "v0.3.0".into(),
1127 current_version: "0.1.52".into(),
1128 release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0".into(),
1129 is_newer: true,
1130 is_skipped: false,
1131 };
1132 assert!(
1133 newer_info.should_show(),
1134 "should show banner for version newer than skipped"
1135 );
1136 }
1137
1138 #[test]
1139 #[serial]
1140 fn test_check_interval_respects_cadence() {
1141 let mut state = UpdateState::default();
1142
1143 assert!(state.should_check());
1145
1146 state.mark_checked();
1148 assert!(!state.should_check());
1149
1150 state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1152 assert!(!state.should_check());
1153
1154 state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1156 assert!(state.should_check());
1157 }
1158
1159 #[test]
1160 #[serial]
1161 fn test_github_repo_constant_is_valid() {
1162 assert!(GITHUB_REPO.contains('/'));
1164 let parts: Vec<&str> = GITHUB_REPO.split('/').collect();
1165 assert_eq!(parts.len(), 2, "should be owner/repo format");
1166 assert!(!parts[0].is_empty(), "owner should not be empty");
1167 assert!(!parts[1].is_empty(), "repo should not be empty");
1168 assert_eq!(parts[0], "Dicklesworthstone");
1169 assert_eq!(parts[1], "coding_agent_session_search");
1170 }
1171
1172 fn http_response(status: u16, body: &str) -> String {
1179 format!(
1180 "HTTP/1.1 {} {}\r\n\
1181 Content-Type: application/json\r\n\
1182 Content-Length: {}\r\n\
1183 Connection: close\r\n\
1184 \r\n\
1185 {}",
1186 status,
1187 match status {
1188 200 => "OK",
1189 404 => "Not Found",
1190 500 => "Internal Server Error",
1191 _ => "Unknown",
1192 },
1193 body.len(),
1194 body
1195 )
1196 }
1197
1198 fn start_test_server(
1200 response_body: &str,
1201 status: u16,
1202 ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1203 use std::io::{Read, Write};
1204 use std::net::TcpListener;
1205
1206 let listener = TcpListener::bind("127.0.0.1:0").expect("bind to ephemeral port");
1207 let addr = listener.local_addr().expect("get local addr");
1208
1209 let response = http_response(status, response_body);
1210
1211 let handle = std::thread::spawn(move || {
1212 if let Ok((mut stream, _)) = listener.accept() {
1214 let mut buf = [0u8; 1024];
1215 let _ = stream.read(&mut buf);
1216 let _ = stream.write_all(response.as_bytes());
1217 let _ = stream.flush();
1218 }
1219 });
1220
1221 std::thread::sleep(std::time::Duration::from_millis(10));
1223
1224 (addr, handle)
1225 }
1226
1227 #[test]
1228 #[serial]
1229 fn integration_fetch_release_success() {
1230 let release_json = r#"{
1232 "tag_name": "v0.2.0",
1233 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1234 }"#;
1235
1236 let (addr, handle) = start_test_server(release_json, 200);
1237
1238 unsafe {
1242 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1243 }
1244
1245 let result = fetch_latest_release_blocking();
1247
1248 unsafe {
1250 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1251 }
1252
1253 handle.join().expect("server thread");
1254
1255 let release = result.expect("fetch should succeed");
1256 assert_eq!(release.tag_name, "v0.2.0");
1257 assert!(release.html_url.contains("v0.2.0"));
1258 }
1259
1260 #[test]
1261 #[serial]
1262 fn integration_fetch_release_404_error() {
1263 let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1264
1265 unsafe {
1266 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1267 }
1268
1269 let result = fetch_latest_release_blocking();
1270
1271 unsafe {
1272 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1273 }
1274
1275 handle.join().expect("server thread");
1276
1277 assert!(result.is_err(), "should return error for 404");
1278 let err = result.unwrap_err();
1279 assert!(
1280 err.to_string().contains("404") || err.to_string().contains("Not Found"),
1281 "error should mention 404: {}",
1282 err
1283 );
1284 }
1285
1286 #[test]
1287 #[serial]
1288 fn integration_fetch_release_malformed_json() {
1289 let (addr, handle) = start_test_server("this is not json", 200);
1290
1291 unsafe {
1292 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1293 }
1294
1295 let result = fetch_latest_release_blocking();
1296
1297 unsafe {
1298 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1299 }
1300
1301 handle.join().expect("server thread");
1302
1303 assert!(result.is_err(), "should return error for malformed JSON");
1304 }
1305
1306 #[test]
1307 #[serial]
1308 fn integration_fetch_release_missing_fields() {
1309 let incomplete_json = r#"{"some_other_field": "value"}"#;
1311
1312 let (addr, handle) = start_test_server(incomplete_json, 200);
1313
1314 unsafe {
1315 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1316 }
1317
1318 let result = fetch_latest_release_blocking();
1319
1320 unsafe {
1321 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1322 }
1323
1324 handle.join().expect("server thread");
1325
1326 assert!(result.is_err(), "should error on missing required fields");
1328 }
1329
1330 #[test]
1331 #[serial]
1332 fn integration_fetch_release_server_error() {
1333 let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1334
1335 unsafe {
1336 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1337 }
1338
1339 let result = fetch_latest_release_blocking();
1340
1341 unsafe {
1342 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1343 }
1344
1345 handle.join().expect("server thread");
1346
1347 assert!(result.is_err(), "should return error for 500");
1348 }
1349
1350 #[test]
1351 #[serial]
1352 fn integration_version_comparison_with_real_fetch() {
1353 let release_json = r#"{
1355 "tag_name": "v0.3.0",
1356 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1357 }"#;
1358
1359 let (addr, handle) = start_test_server(release_json, 200);
1360
1361 unsafe {
1362 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1363 }
1364
1365 let result = fetch_latest_release_blocking();
1366
1367 unsafe {
1368 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1369 }
1370
1371 handle.join().expect("server thread");
1372
1373 let release = result.expect("fetch should succeed");
1374
1375 let latest_str = release.tag_name.trim_start_matches('v');
1377 let latest = Version::parse(latest_str).expect("parse latest version");
1378 let current = Version::parse("0.1.50").expect("parse current version");
1379
1380 assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1381 }
1382
1383 #[test]
1384 #[serial]
1385 fn integration_prerelease_version_handling() {
1386 let release_json = r#"{
1388 "tag_name": "v0.2.0-beta.1",
1389 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1390 }"#;
1391
1392 let (addr, handle) = start_test_server(release_json, 200);
1393
1394 unsafe {
1395 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1396 }
1397
1398 let result = fetch_latest_release_blocking();
1399
1400 unsafe {
1401 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1402 }
1403
1404 handle.join().expect("server thread");
1405
1406 let release = result.expect("fetch should succeed");
1407 let latest_str = release.tag_name.trim_start_matches('v');
1408 let latest = Version::parse(latest_str).expect("parse prerelease version");
1409
1410 let stable = Version::parse("0.2.0").expect("parse stable version");
1412 assert!(
1413 latest < stable,
1414 "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1415 );
1416
1417 let older = Version::parse("0.1.50").expect("parse older version");
1419 assert!(
1420 latest > older,
1421 "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1422 );
1423 }
1424
1425 #[test]
1426 #[serial]
1427 fn integration_connection_refused_is_offline_friendly() {
1428 unsafe {
1430 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1431 }
1432
1433 let result = fetch_latest_release_blocking();
1434
1435 unsafe {
1436 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1437 }
1438
1439 assert!(
1441 result.is_err(),
1442 "should return error when server unreachable"
1443 );
1444 let err = result.unwrap_err();
1446 let err_chain = format!("{:?}", err).to_lowercase();
1447 assert!(
1448 err_chain.contains("connection")
1449 || err_chain.contains("connect")
1450 || err_chain.contains("refused")
1451 || err_chain.contains("fetch")
1452 || err_chain.contains("os error"),
1453 "should be a network/fetch error: {}",
1454 err_chain
1455 );
1456 }
1457
1458 #[test]
1459 #[serial]
1460 fn integration_failed_sync_check_does_not_throttle_future_checks() {
1461 let temp_dir = tempfile::TempDir::new().unwrap();
1462 let state_file = temp_dir.path().join("update_state.json");
1463 unsafe {
1464 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1465 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1466 std::env::remove_var("CASS_SKIP_UPDATE");
1467 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1468 std::env::remove_var("TUI_HEADLESS");
1469 std::env::remove_var("CI");
1470 }
1471
1472 let result = check_for_updates_sync("0.1.0");
1473 assert!(result.is_none(), "offline sync check should fail quietly");
1474
1475 assert!(
1476 !state_file.exists(),
1477 "failed sync checks must not persist cadence state"
1478 );
1479
1480 unsafe {
1481 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1482 std::env::remove_var("CASS_DATA_DIR");
1483 }
1484 }
1485
1486 #[test]
1487 #[serial]
1488 fn integration_failed_async_check_does_not_throttle_future_checks() {
1489 let temp_dir = tempfile::TempDir::new().unwrap();
1490 let state_file = temp_dir.path().join("update_state.json");
1491 unsafe {
1492 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1493 std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1494 std::env::remove_var("CASS_SKIP_UPDATE");
1495 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1496 std::env::remove_var("TUI_HEADLESS");
1497 std::env::remove_var("CI");
1498 }
1499
1500 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1501 .build()
1502 .expect("build test runtime");
1503 let result = runtime.block_on(check_for_updates("0.1.0"));
1504 assert!(result.is_none(), "offline async check should fail quietly");
1505
1506 assert!(
1507 !state_file.exists(),
1508 "failed async checks must not persist cadence state"
1509 );
1510
1511 unsafe {
1512 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1513 std::env::remove_var("CASS_DATA_DIR");
1514 }
1515 }
1516
1517 #[cfg(unix)]
1518 #[test]
1519 #[serial]
1520 fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1521 use std::os::unix::fs::PermissionsExt;
1522
1523 let temp_dir = tempfile::TempDir::new().unwrap();
1524 let state_file = temp_dir.path().join("update_state.json");
1525 let state = UpdateState {
1526 last_check_ts: now_unix(),
1527 skipped_version: None,
1528 };
1529 std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1530
1531 let release_json = r#"{
1532 "tag_name": "v9.9.9",
1533 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1534 }"#;
1535 let (addr, handle) = start_test_server(release_json, 200);
1536
1537 let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1538 let file_metadata = std::fs::metadata(&state_file).unwrap();
1539 let dir_mode = dir_metadata.permissions().mode();
1540 let file_mode = file_metadata.permissions().mode();
1541
1542 let mut readonly_dir = dir_metadata.permissions();
1543 readonly_dir.set_mode(0o555);
1544 std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1545
1546 let mut readonly_file = file_metadata.permissions();
1547 readonly_file.set_mode(0o444);
1548 std::fs::set_permissions(&state_file, readonly_file).unwrap();
1549
1550 unsafe {
1551 std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1552 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1553 std::env::remove_var("CASS_SKIP_UPDATE");
1554 std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1555 std::env::remove_var("TUI_HEADLESS");
1556 std::env::remove_var("CI");
1557 }
1558
1559 let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1560 .build()
1561 .expect("build test runtime");
1562 let result = runtime.block_on(force_check("0.1.0"));
1563
1564 let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1565 restore_file.set_mode(file_mode);
1566 std::fs::set_permissions(&state_file, restore_file).unwrap();
1567
1568 let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1569 restore_dir.set_mode(dir_mode);
1570 std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1571
1572 unsafe {
1573 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1574 std::env::remove_var("CASS_DATA_DIR");
1575 }
1576
1577 handle.join().expect("server thread");
1578
1579 let info = result.expect("force check should bypass cadence and succeed");
1580 assert_eq!(info.latest_version, "9.9.9");
1581 assert!(info.is_newer);
1582 }
1583
1584 #[test]
1585 #[serial]
1586 fn integration_blocking_fetch_release_success_v1() {
1587 let release_json = r#"{
1589 "tag_name": "v1.0.0",
1590 "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1591 }"#;
1592
1593 let (addr, handle) = start_test_server(release_json, 200);
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 let release = result.expect("blocking fetch should succeed");
1608 assert_eq!(release.tag_name, "v1.0.0");
1609 }
1610
1611 #[test]
1612 #[serial]
1613 fn integration_blocking_fetch_release_403_error() {
1614 let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1615
1616 unsafe {
1617 std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1618 }
1619
1620 let result = fetch_latest_release_blocking();
1621
1622 unsafe {
1623 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1624 }
1625
1626 handle.join().expect("server thread");
1627
1628 assert!(result.is_err(), "should error on 403");
1629 }
1630
1631 #[test]
1632 #[serial]
1633 fn integration_release_api_base_url_default() {
1634 unsafe {
1636 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1637 }
1638
1639 let url = release_api_base_url();
1640 assert!(
1641 url.contains("api.github.com"),
1642 "default should use GitHub API"
1643 );
1644 assert!(
1645 url.contains(GITHUB_REPO),
1646 "default should include repo path"
1647 );
1648 }
1649
1650 #[test]
1651 #[serial]
1652 fn integration_release_api_base_url_override() {
1653 let custom_url = "http://localhost:8080/api";
1654 unsafe {
1655 std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1656 }
1657
1658 let url = release_api_base_url();
1659
1660 unsafe {
1661 std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1662 }
1663
1664 assert_eq!(url, custom_url, "should use custom URL from env var");
1665 }
1666
1667 #[test]
1668 #[serial]
1669 fn integration_http_timeout_is_reasonable() {
1670 const _: () = {
1671 assert!(
1673 HTTP_TIMEOUT_SECS <= 10,
1674 "HTTP timeout should be short to avoid blocking startup"
1675 );
1676 assert!(
1677 HTTP_TIMEOUT_SECS >= 3,
1678 "HTTP timeout should be long enough for slow networks"
1679 );
1680 };
1681 }
1682
1683 #[test]
1684 #[serial]
1685 fn integration_check_interval_is_reasonable() {
1686 const _: () = {
1687 assert!(
1689 CHECK_INTERVAL_SECS >= 3600,
1690 "should not check more than once per hour"
1691 );
1692 assert!(
1693 CHECK_INTERVAL_SECS <= 86400,
1694 "should check at least once per day"
1695 );
1696 };
1697 }
1698}