Skip to main content

coding_agent_search/
update_check.rs

1//! Update checker for release notifications.
2//!
3//! Provides non-blocking release checking with:
4//! - GitHub releases API integration
5//! - Persistent state (last check time, skipped versions)
6//! - Offline-friendly behavior (silent failure)
7//! - Hourly check cadence (configurable)
8
9use 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
17/// How often to check for updates (1 hour default)
18const CHECK_INTERVAL_SECS: u64 = 3600;
19
20/// Timeout for HTTP requests (short to avoid blocking startup)
21const HTTP_TIMEOUT_SECS: u64 = 5;
22
23/// GitHub repo for release checks
24const 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/// Persistent state for update checker
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41pub struct UpdateState {
42    /// Unix timestamp of last successful check
43    pub last_check_ts: i64,
44    /// Version string that user chose to skip (e.g., "0.2.0")
45    pub skipped_version: Option<String>,
46}
47
48impl UpdateState {
49    /// Load state from disk (synchronous)
50    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    /// Load state from disk (asynchronous)
67    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    /// Save state to disk (synchronous)
84    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    /// Save state to disk (asynchronous)
96    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    /// Check if enough time has passed since last check
111    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    /// Mark that we just checked
120    pub fn mark_checked(&mut self) {
121        self.last_check_ts = now_unix();
122    }
123
124    /// Skip a specific version
125    pub fn skip_version(&mut self, version: &str) {
126        self.skipped_version = Some(version.to_string());
127    }
128
129    /// Check if a version is skipped
130    pub fn is_skipped(&self, version: &str) -> bool {
131        self.skipped_version.as_deref() == Some(version)
132    }
133
134    /// Clear skip preference (on upgrade or manual clear)
135    pub fn clear_skip(&mut self) {
136        self.skipped_version = None;
137    }
138}
139
140/// Information about an available update
141#[derive(Debug, Clone)]
142pub struct UpdateInfo {
143    /// Latest version available
144    pub latest_version: String,
145    /// Git tag name for the release
146    pub tag_name: String,
147    /// Current running version
148    pub current_version: String,
149    /// URL to release notes
150    pub release_url: String,
151    /// Whether latest is newer than current
152    pub is_newer: bool,
153    /// Whether user has skipped this version
154    pub is_skipped: bool,
155}
156
157impl UpdateInfo {
158    /// Check if we should show the update banner
159    pub fn should_show(&self) -> bool {
160        self.is_newer && !self.is_skipped
161    }
162}
163
164/// GitHub release API response (minimal fields)
165#[derive(Debug, Deserialize)]
166struct GitHubRelease {
167    tag_name: String,
168    html_url: String,
169}
170
171/// Check for updates asynchronously
172///
173/// Returns None if:
174/// - Not enough time since last successful check
175/// - Network error (offline-friendly)
176/// - Parse error
177pub 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    // Escape hatch for CI/CD or restricted environments
183    if updates_disabled() {
184        return None;
185    }
186
187    let mut state = UpdateState::load_async().await;
188
189    // Respect check interval
190    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    // Persist cadence only after a successful fetch + parse so transient
206    // network or server errors do not suppress future checks for an hour.
207    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
215/// Force a check regardless of interval (for manual refresh)
216pub async fn force_check(current_version: &str) -> Option<UpdateInfo> {
217    check_for_updates_async_impl(current_version, true).await
218}
219
220/// Skip the specified version
221pub fn skip_version(version: &str) -> Result<()> {
222    let mut state = UpdateState::load();
223    state.skip_version(version);
224    state.save()
225}
226
227/// Open a URL in the system's default browser
228pub 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
432/// Run the self-update installer script interactively.
433/// This function does NOT return - it replaces the current process with the installer.
434/// The caller should ensure the terminal is in a clean state before calling.
435pub fn run_self_update(version: &str) -> ! {
436    // Defense-in-depth: require the same release tag shape accepted from
437    // GitHub metadata before interpolating the tag into release asset URLs.
438    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        // Use positional args instead of string interpolation to prevent injection.
450        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        // If we get here, exec failed
462        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        // Windows doesn't have exec(), so we spawn and wait.
472        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
495/// Get the base URL for release API. Overridable for testing via the
496/// `CASS_UPDATE_API_BASE_URL` env var, but the override is validated
497/// against an allow-list of schemes + hosts so a malicious `.env` or
498/// shell environment can't redirect the release-metadata fetch to an
499/// attacker-controlled server (beads
500/// `coding_agent_session_search-87sqx`,
501/// `coding_agent_session_search-6bvx8`). Allowed forms:
502///   - `https://api.github.com/...`
503///   - `https://github.com/...`
504///   - `http://127.0.0.1:<port>...` (local integration tests)
505///   - `http://localhost:<port>...` (local integration tests)
506///
507/// Any other value falls back to the default GitHub URL with a
508/// one-shot stderr warning.
509fn 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
526/// Scheme + host allow-list check for `CASS_UPDATE_API_BASE_URL`
527/// overrides. Kept as a small pure helper so the unit tests at the
528/// bottom of this module can pin every accept/reject case
529/// independently of the env-var plumbing.
530fn 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
548/// Get path to update state file
549fn 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
560/// Current unix timestamp
561fn 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
571// ============================================================================
572// Synchronous API for TUI (blocking HTTP)
573// ============================================================================
574
575/// Synchronous version of `check_for_updates` for use in sync TUI code.
576/// Uses a short-lived asupersync runtime and native HTTP client.
577pub 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    // Respect check interval
585    if !state.should_check() {
586        debug!("update check: skipping, checked recently");
587        return None;
588    }
589
590    // Fetch latest release (blocking)
591    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    // Persist cadence only after a successful fetch + parse so transient
602    // network or server errors do not suppress future checks for an hour.
603    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
649/// Fetch latest release using the native asupersync HTTP client.
650async 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
708/// Fetch latest release using a dedicated synchronous runtime.
709fn 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
716/// Start a background thread to check for updates.
717/// Returns a receiver that will contain the result when ready.
718pub 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(&current_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    /// `coding_agent_session_search-87sqx` / `coding_agent_session_search-6bvx8`: the allow-list on
932    /// `CASS_UPDATE_API_BASE_URL` must reject non-https overrides
933    /// against non-loopback hosts and non-GitHub HTTPS hosts (malicious .env / shell pollution)
934    /// while still permitting the `http://127.0.0.1:<port>` form the
935    /// integration tests below use.
936    #[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        // Prefix attack: host must match exactly, not be a prefix
974        // of a longer attacker-controlled hostname.
975        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        // Empty-host https:// — reject so the URL parser doesn't see a
987        // malformed-but-parseable URL.
988        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()); // Fresh state should check
997
998        state.mark_checked();
999        assert!(!state.should_check()); // Just checked, should not check again
1000
1001        // Simulate time passing
1002        state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1003        assert!(state.should_check()); // Enough time passed
1004
1005        // Future timestamps should not suppress checks indefinitely after
1006        // clock skew or state-file corruption.
1007        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    // =========================================================================
1066    // Upgrade Process Tests
1067    // =========================================================================
1068
1069    #[test]
1070    #[serial]
1071    fn test_version_comparison_upgrade_scenarios() {
1072        // Test various upgrade scenarios with semver comparison
1073        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        // Create state with specific values
1107        let mut state = UpdateState {
1108            last_check_ts: 1234567890,
1109            skipped_version: Some("0.1.50".to_string()),
1110        };
1111
1112        // Write to temp location
1113        let json = serde_json::to_string_pretty(&state).unwrap();
1114        std::fs::write(&state_file, &json).unwrap();
1115
1116        // Read back
1117        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        // Modify and save again
1126        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")); // Only latest skip is stored
1135    }
1136
1137    #[test]
1138    #[serial]
1139    fn test_update_info_upgrade_workflow() {
1140        // Simulate the full upgrade decision workflow
1141
1142        // Case 1: New version available, not skipped -> should show
1143        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        // Case 2: User skips this version
1155        let mut state = UpdateState::default();
1156        state.skip_version(&info.latest_version);
1157        assert!(state.is_skipped(&info.latest_version));
1158
1159        // Now the info should not show (simulating re-check)
1160        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        // Case 3: New version beyond skipped -> should show again
1170        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        // Fresh state should check
1191        assert!(state.should_check());
1192
1193        // After checking, should not check again immediately
1194        state.mark_checked();
1195        assert!(!state.should_check());
1196
1197        // After half the interval, still should not check
1198        state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1199        assert!(!state.should_check());
1200
1201        // After full interval, should check again
1202        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        // Verify the repo constant is properly formatted
1210        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    // =========================================================================
1220    // Integration Tests with Local HTTP Server (br-e3ze)
1221    // Tests real HTTP client behavior against ephemeral local servers
1222    // =========================================================================
1223
1224    /// Helper to create a simple HTTP response
1225    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    /// Start a simple HTTP server on an ephemeral port that serves a single response
1246    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            // Accept one connection and respond
1260            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        // Small delay to ensure server is ready
1269        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        // Start local server with valid release JSON
1278        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        // Set env var to point to our local server
1286        // Safety: Tests run sequentially in same process, but this is still racy
1287        // We use a unique port each time so it's safe for our purposes
1288        unsafe {
1289            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1290        }
1291
1292        // Make the request using blocking client
1293        let result = fetch_latest_release_blocking();
1294
1295        // Clean up env var
1296        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        // JSON that doesn't have required fields
1357        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        // Should fail to parse because tag_name is missing
1374        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        // Test the full flow: fetch -> parse -> compare
1401        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        // Parse and compare versions like the real code does
1423        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        // Test handling of pre-release versions from server
1434        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        // Prerelease 0.2.0-beta.1 should be less than 0.2.0
1458        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        // But newer than 0.1.50
1465        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        // Point to a port that's not listening
1476        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        // Should fail gracefully, not panic
1487        assert!(
1488            result.is_err(),
1489            "should return error when server unreachable"
1490        );
1491        // The error is wrapped in context, so check the full chain
1492        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        // Validates the synchronous wrapper over the native HTTP client.
1635        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        // When env var is not set, should use GitHub API
1682        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            // Verify the timeout constant is short enough for startup
1719            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            // Verify check interval is reasonable (not too frequent, not too rare)
1735            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}