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