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