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::{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        let temp_path = write_update_state_temp_file(&path, json.as_bytes())
92            .with_context(|| format!("writing temporary update state for {}", path.display()))?;
93        replace_update_state_file_from_temp(&temp_path, &path)
94            .with_context(|| format!("replacing {}", path.display()))?;
95        Ok(())
96    }
97
98    /// Save state to disk (asynchronous)
99    pub async fn save_async(&self) -> Result<()> {
100        let path = state_path();
101        if let Some(parent) = path.parent() {
102            asupersync::fs::create_dir_all(parent)
103                .await
104                .with_context(|| format!("creating update state directory {}", parent.display()))?;
105        }
106        let json = serde_json::to_string_pretty(self).context("serializing update state")?;
107        let temp_path = write_update_state_temp_file_async(&path, json.as_bytes())
108            .await
109            .with_context(|| format!("writing temporary update state for {}", path.display()))?;
110        replace_update_state_file_from_temp(&temp_path, &path)
111            .with_context(|| format!("replacing {}", path.display()))?;
112        Ok(())
113    }
114
115    /// Check if enough time has passed since last check
116    pub fn should_check(&self) -> bool {
117        let now = now_unix();
118        if self.last_check_ts <= 0 || self.last_check_ts > now {
119            return true;
120        }
121        now.saturating_sub(self.last_check_ts) >= CHECK_INTERVAL_SECS as i64
122    }
123
124    /// Mark that we just checked
125    pub fn mark_checked(&mut self) {
126        self.last_check_ts = now_unix();
127    }
128
129    /// Skip a specific version
130    pub fn skip_version(&mut self, version: &str) {
131        self.skipped_version = Some(version.to_string());
132    }
133
134    /// Check if a version is skipped
135    pub fn is_skipped(&self, version: &str) -> bool {
136        self.skipped_version.as_deref() == Some(version)
137    }
138
139    /// Clear skip preference (on upgrade or manual clear)
140    pub fn clear_skip(&mut self) {
141        self.skipped_version = None;
142    }
143}
144
145/// Information about an available update
146#[derive(Debug, Clone)]
147pub struct UpdateInfo {
148    /// Latest version available
149    pub latest_version: String,
150    /// Git tag name for the release
151    pub tag_name: String,
152    /// Current running version
153    pub current_version: String,
154    /// URL to release notes
155    pub release_url: String,
156    /// Whether latest is newer than current
157    pub is_newer: bool,
158    /// Whether user has skipped this version
159    pub is_skipped: bool,
160}
161
162impl UpdateInfo {
163    /// Check if we should show the update banner
164    pub fn should_show(&self) -> bool {
165        self.is_newer && !self.is_skipped
166    }
167}
168
169/// GitHub release API response (minimal fields)
170#[derive(Debug, Deserialize)]
171struct GitHubRelease {
172    tag_name: String,
173    html_url: String,
174}
175
176/// Check for updates asynchronously
177///
178/// Returns None if:
179/// - Not enough time since last successful check
180/// - Network error (offline-friendly)
181/// - Parse error
182pub async fn check_for_updates(current_version: &str) -> Option<UpdateInfo> {
183    check_for_updates_async_impl(current_version, false).await
184}
185
186async fn check_for_updates_async_impl(current_version: &str, force: bool) -> Option<UpdateInfo> {
187    // Escape hatch for CI/CD or restricted environments
188    if updates_disabled() {
189        return None;
190    }
191
192    let mut state = UpdateState::load_async().await;
193
194    // Respect check interval
195    if !force && !state.should_check() {
196        debug!("update check: skipping, checked recently");
197        return None;
198    }
199
200    let release = match fetch_latest_release().await {
201        Ok(r) => r,
202        Err(e) => {
203            debug!("update check: fetch failed (offline?): {e}");
204            return None;
205        }
206    };
207
208    let info = build_update_info(current_version, release, &state)?;
209
210    // Persist cadence only after a successful fetch + parse so transient
211    // network or server errors do not suppress future checks for an hour.
212    state.mark_checked();
213    if let Err(e) = state.save_async().await {
214        warn!("update check: failed to save state: {e}");
215    }
216
217    Some(info)
218}
219
220/// Force a check regardless of interval (for manual refresh)
221pub async fn force_check(current_version: &str) -> Option<UpdateInfo> {
222    check_for_updates_async_impl(current_version, true).await
223}
224
225/// Skip the specified version
226pub fn skip_version(version: &str) -> Result<()> {
227    let mut state = UpdateState::load();
228    state.skip_version(version);
229    state.save()
230}
231
232/// Open a URL in the system's default browser
233pub fn open_in_browser(url: &str) -> std::io::Result<()> {
234    validate_browser_url(url)?;
235
236    #[cfg(target_os = "windows")]
237    {
238        std::process::Command::new("rundll32")
239            .args(["url.dll,FileProtocolHandler", url])
240            .spawn()?;
241    }
242    #[cfg(target_os = "macos")]
243    {
244        std::process::Command::new("open").arg(url).spawn()?;
245    }
246    #[cfg(target_os = "linux")]
247    {
248        std::process::Command::new("xdg-open").arg(url).spawn()?;
249    }
250    Ok(())
251}
252
253fn validate_browser_url(url: &str) -> std::io::Result<()> {
254    if is_browser_url(url) {
255        Ok(())
256    } else {
257        Err(std::io::Error::new(
258            std::io::ErrorKind::InvalidInput,
259            "release notes URL must be an absolute http(s) URL",
260        ))
261    }
262}
263
264fn is_browser_url(url: &str) -> bool {
265    let Ok(parsed) = url::Url::parse(url) else {
266        return false;
267    };
268    if url_has_userinfo(&parsed) {
269        return false;
270    }
271    matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some()
272}
273
274fn is_trusted_release_notes_url(url: &str) -> bool {
275    let Ok(parsed) = url::Url::parse(url) else {
276        return false;
277    };
278    if parsed.scheme() != "https"
279        || parsed.host_str() != Some("github.com")
280        || url_has_userinfo(&parsed)
281    {
282        return false;
283    }
284
285    let Some((expected_owner, expected_repo)) = GITHUB_REPO.split_once('/') else {
286        return false;
287    };
288    let Some(mut path_segments) = parsed.path_segments() else {
289        return false;
290    };
291    let Some(owner) = path_segments.next() else {
292        return false;
293    };
294    let Some(repo) = path_segments.next() else {
295        return false;
296    };
297    let Some(section) = path_segments.next() else {
298        return false;
299    };
300
301    owner.eq_ignore_ascii_case(expected_owner)
302        && repo.eq_ignore_ascii_case(expected_repo)
303        && section == "releases"
304}
305
306fn url_has_userinfo(url: &url::Url) -> bool {
307    !url.username().is_empty() || url.password().is_some()
308}
309
310fn release_asset_url(version: &str, asset: &str) -> String {
311    format!("https://github.com/{GITHUB_REPO}/releases/download/{version}/{asset}")
312}
313
314fn parse_update_tag(tag: &str) -> Option<(&str, Version)> {
315    if tag.trim() != tag {
316        return None;
317    }
318
319    let version = tag.strip_prefix('v').unwrap_or(tag);
320    let parsed = Version::parse(version).ok()?;
321    Some((version, parsed))
322}
323
324fn is_valid_update_tag(tag: &str) -> bool {
325    parse_update_tag(tag).is_some()
326}
327
328#[cfg(any(test, target_os = "macos", target_os = "linux"))]
329fn unix_self_update_script() -> &'static str {
330    r#"
331set -euo pipefail
332
333tmp="$(mktemp -d "${TMPDIR:-/tmp}/cass-self-update.XXXXXX")"
334cleanup() {
335    rm -r "$tmp" 2>/dev/null || true
336}
337trap cleanup EXIT
338
339script="$tmp/install.sh"
340sums="$tmp/SHA256SUMS.txt"
341curl -fsSL "$1" -o "$script"
342expected=""
343for checksums_url in "$2" "$4"; do
344    [ -n "$checksums_url" ] || continue
345    if ! curl -fsSL "$checksums_url" -o "$sums"; then
346        continue
347    fi
348    candidate="$(awk '$2 == "install.sh" { print $1; exit }' "$sums")"
349    if printf '%s' "$candidate" | grep -Eq '^[0-9a-fA-F]{64}$'; then
350        expected="$candidate"
351        break
352    fi
353done
354if ! printf '%s' "$expected" | grep -Eq '^[0-9a-fA-F]{64}$'; then
355    echo "install.sh checksum missing from release checksum manifests" >&2
356    exit 1
357fi
358expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')"
359
360if command -v sha256sum >/dev/null 2>&1; then
361    printf '%s  %s\n' "$expected_lc" "$script" | sha256sum -c -
362elif command -v shasum >/dev/null 2>&1; then
363    actual="$(shasum -a 256 "$script" | awk '{ print $1 }' | tr '[:upper:]' '[:lower:]')"
364    if [ "$actual" != "$expected_lc" ]; then
365        echo "install.sh checksum mismatch" >&2
366        exit 1
367    fi
368elif command -v openssl >/dev/null 2>&1; then
369    actual="$(openssl dgst -sha256 "$script" | awk '{ print $NF }' | tr '[:upper:]' '[:lower:]')"
370    if [ "$actual" != "$expected_lc" ]; then
371        echo "install.sh checksum mismatch" >&2
372        exit 1
373    fi
374else
375    echo "No SHA-256 verification tool found" >&2
376    exit 1
377fi
378
379exec bash "$script" --easy-mode --verify --version "$3"
380"#
381}
382
383#[cfg(any(test, target_os = "windows"))]
384fn windows_self_update_script() -> &'static str {
385    r#"
386$InstallUrl = $args[0]
387$ChecksumsUrl = $args[1]
388$Version = $args[2]
389$Temp = Join-Path ([IO.Path]::GetTempPath()) ("cass-self-update-" + [guid]::NewGuid().ToString("N"))
390New-Item -ItemType Directory -Path $Temp -Force | Out-Null
391try {
392    $Script = Join-Path $Temp "install.ps1"
393    $Sums = Join-Path $Temp "SHA256SUMS.txt"
394    Invoke-WebRequest -Uri $InstallUrl -OutFile $Script -UseBasicParsing
395
396    $Expected = $null
397    foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3])) {
398        if (-not $ChecksumsCandidateUrl) {
399            continue
400        }
401        try {
402            Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums -UseBasicParsing
403        } catch {
404            continue
405        }
406
407        foreach ($Line in Get-Content -LiteralPath $Sums) {
408            $Parts = $Line.Trim() -split '\s+', 2
409            if ($Parts.Count -ge 2 -and $Parts[1] -eq "install.ps1" -and $Parts[0] -match '^[0-9a-fA-F]{64}$') {
410                $Expected = $Parts[0].ToLowerInvariant()
411                break
412            }
413        }
414        if ($Expected) {
415            break
416        }
417    }
418    if (-not $Expected) {
419        Write-Error "install.ps1 checksum missing from release checksum manifests"
420        exit 1
421    }
422
423    $Actual = (Get-FileHash -LiteralPath $Script -Algorithm SHA256).Hash.ToLowerInvariant()
424    if ($Actual -ne $Expected) {
425        Write-Error "install.ps1 checksum mismatch"
426        exit 1
427    }
428
429    & $Script -EasyMode -Verify -Version $Version
430    exit $LASTEXITCODE
431} finally {
432    Remove-Item -LiteralPath $Temp -Recurse -Force -ErrorAction SilentlyContinue
433}
434"#
435}
436
437/// Run the self-update installer script interactively.
438/// This function does NOT return - it replaces the current process with the installer.
439/// The caller should ensure the terminal is in a clean state before calling.
440pub fn run_self_update(version: &str) -> ! {
441    // Defense-in-depth: require the same release tag shape accepted from
442    // GitHub metadata before interpolating the tag into release asset URLs.
443    if !is_valid_update_tag(version) {
444        eprintln!("Invalid version string: {}", version);
445        std::process::exit(1);
446    }
447
448    #[cfg(any(target_os = "macos", target_os = "linux"))]
449    {
450        use std::os::unix::process::CommandExt;
451        let install_url = release_asset_url(version, UNIX_INSTALL_ASSET);
452        let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
453        let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
454        // Use positional args instead of string interpolation to prevent injection.
455        let err = std::process::Command::new("bash")
456            .args([
457                "-c",
458                unix_self_update_script(),
459                "cass-updater",
460                &install_url,
461                &checksums_url,
462                version,
463                &checksums_alt_url,
464            ])
465            .exec();
466        // If we get here, exec failed
467        eprintln!("Failed to run installer: {}", err);
468        std::process::exit(1);
469    }
470
471    #[cfg(target_os = "windows")]
472    {
473        let install_url = release_asset_url(version, WINDOWS_INSTALL_ASSET);
474        let checksums_url = release_asset_url(version, CHECKSUMS_ASSET);
475        let checksums_alt_url = release_asset_url(version, CHECKSUMS_ASSET_ALT);
476        // Windows doesn't have exec(), so we spawn and wait.
477        let status = std::process::Command::new("powershell")
478            .args([
479                "-ExecutionPolicy",
480                "Bypass",
481                "-NoProfile",
482                "-Command",
483                windows_self_update_script(),
484                &install_url,
485                &checksums_url,
486                version,
487                &checksums_alt_url,
488            ])
489            .status();
490        match status {
491            Ok(s) => std::process::exit(s.code().unwrap_or(0)),
492            Err(e) => {
493                eprintln!("Failed to run installer: {}", e);
494                std::process::exit(1);
495            }
496        }
497    }
498}
499
500/// Get the base URL for release API. Overridable for testing via the
501/// `CASS_UPDATE_API_BASE_URL` env var, but the override is validated
502/// against an allow-list of schemes + hosts so a malicious `.env` or
503/// shell environment can't redirect the release-metadata fetch to an
504/// attacker-controlled server (beads
505/// `coding_agent_session_search-87sqx`,
506/// `coding_agent_session_search-6bvx8`). Allowed forms:
507///   - `https://api.github.com/...`
508///   - `https://github.com/...`
509///   - `http://127.0.0.1:<port>...` (local integration tests)
510///   - `http://localhost:<port>...` (local integration tests)
511///
512/// Any other value falls back to the default GitHub URL with a
513/// one-shot stderr warning.
514fn release_api_base_url() -> String {
515    let default = || format!("https://api.github.com/repos/{GITHUB_REPO}");
516    let Ok(override_url) = dotenvy::var("CASS_UPDATE_API_BASE_URL") else {
517        return default();
518    };
519    if is_allowed_update_api_url(&override_url) {
520        override_url
521    } else {
522        eprintln!(
523            "warning: CASS_UPDATE_API_BASE_URL={override_url:?} ignored \
524             (only GitHub HTTPS URLs or http://localhost/127.0.0.1 test endpoints allowed). \
525             Falling back to the default GitHub release API."
526        );
527        default()
528    }
529}
530
531/// Scheme + host allow-list check for `CASS_UPDATE_API_BASE_URL`
532/// overrides. Kept as a small pure helper so the unit tests at the
533/// bottom of this module can pin every accept/reject case
534/// independently of the env-var plumbing.
535fn is_allowed_update_api_url(url: &str) -> bool {
536    let Ok(parsed) = url::Url::parse(url) else {
537        return false;
538    };
539    let Some(host) = parsed.host_str() else {
540        return false;
541    };
542    if url_has_userinfo(&parsed) {
543        return false;
544    }
545
546    match parsed.scheme() {
547        "https" => matches!(host, "api.github.com" | "github.com"),
548        "http" => matches!(host, "127.0.0.1" | "localhost" | "::1" | "[::1]"),
549        _ => false,
550    }
551}
552
553/// Get path to update state file
554fn state_path() -> PathBuf {
555    crate::default_data_dir().join("update_state.json")
556}
557
558fn legacy_state_path() -> PathBuf {
559    directories::ProjectDirs::from("com", "coding-agent-search", "coding-agent-search").map_or_else(
560        || PathBuf::from("update_state.json"),
561        |dirs| dirs.data_dir().join("update_state.json"),
562    )
563}
564
565fn write_update_state_temp_file(path: &Path, contents: &[u8]) -> std::io::Result<PathBuf> {
566    for _ in 0..100 {
567        let temp_path = unique_update_state_temp_path(path);
568        match write_update_state_temp_file_at(&temp_path, contents) {
569            Ok(()) => return Ok(temp_path),
570            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
571            Err(err) => return Err(err),
572        }
573    }
574
575    Err(std::io::Error::new(
576        std::io::ErrorKind::AlreadyExists,
577        format!(
578            "failed to allocate unique update state temp path for {}",
579            path.display()
580        ),
581    ))
582}
583
584fn write_update_state_temp_file_at(path: &Path, contents: &[u8]) -> std::io::Result<()> {
585    use std::io::Write;
586
587    let mut file = std::fs::OpenOptions::new()
588        .write(true)
589        .create_new(true)
590        .open(path)?;
591    file.write_all(contents)?;
592    file.sync_all()
593}
594
595async fn write_update_state_temp_file_async(
596    path: &Path,
597    contents: &[u8],
598) -> std::io::Result<PathBuf> {
599    for _ in 0..100 {
600        let temp_path = unique_update_state_temp_path(path);
601        match write_update_state_temp_file_at_async(&temp_path, contents).await {
602            Ok(()) => return Ok(temp_path),
603            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
604            Err(err) => return Err(err),
605        }
606    }
607
608    Err(std::io::Error::new(
609        std::io::ErrorKind::AlreadyExists,
610        format!(
611            "failed to allocate unique update state temp path for {}",
612            path.display()
613        ),
614    ))
615}
616
617async fn write_update_state_temp_file_at_async(
618    path: &Path,
619    contents: &[u8],
620) -> std::io::Result<()> {
621    use asupersync::io::AsyncWriteExt;
622
623    let mut file = asupersync::fs::OpenOptions::new()
624        .write(true)
625        .create_new(true)
626        .open(path)
627        .await?;
628    file.write_all(contents).await?;
629    file.sync_all().await
630}
631
632fn replace_update_state_file_from_temp(temp_path: &Path, final_path: &Path) -> std::io::Result<()> {
633    #[cfg(windows)]
634    {
635        match std::fs::rename(temp_path, final_path) {
636            Ok(()) => sync_parent_directory(final_path),
637            Err(first_err)
638                if final_path.exists()
639                    && matches!(
640                        first_err.kind(),
641                        std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
642                    ) =>
643            {
644                let backup_path = unique_update_state_backup_path(final_path);
645                std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
646                    std::io::Error::other(format!(
647                        "failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
648                        backup_path.display(),
649                        final_path.display(),
650                        first_err,
651                        backup_err
652                    ))
653                })?;
654                match std::fs::rename(temp_path, final_path) {
655                    Ok(()) => sync_parent_directory(final_path),
656                    Err(second_err) => match std::fs::rename(&backup_path, final_path) {
657                        Ok(()) => {
658                            sync_parent_directory(final_path)?;
659                            Err(std::io::Error::other(format!(
660                                "failed replacing {} with {}: first error: {}; second error: {}; restored original file; temp file retained at {}",
661                                final_path.display(),
662                                temp_path.display(),
663                                first_err,
664                                second_err,
665                                temp_path.display()
666                            )))
667                        }
668                        Err(restore_err) => Err(std::io::Error::other(format!(
669                            "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
670                            final_path.display(),
671                            temp_path.display(),
672                            first_err,
673                            second_err,
674                            restore_err,
675                            temp_path.display()
676                        ))),
677                    },
678                }
679            }
680            Err(err) => Err(err),
681        }
682    }
683
684    #[cfg(not(windows))]
685    {
686        std::fs::rename(temp_path, final_path)?;
687        sync_parent_directory(final_path)
688    }
689}
690
691#[cfg(not(windows))]
692fn sync_parent_directory(path: &Path) -> std::io::Result<()> {
693    let Some(parent) = path.parent() else {
694        return Ok(());
695    };
696    std::fs::File::open(parent)?.sync_all()
697}
698
699#[cfg(windows)]
700fn sync_parent_directory(_path: &Path) -> std::io::Result<()> {
701    Ok(())
702}
703
704fn unique_update_state_temp_path(path: &Path) -> PathBuf {
705    unique_update_state_sidecar_path(path, "tmp")
706}
707
708#[cfg(windows)]
709fn unique_update_state_backup_path(path: &Path) -> PathBuf {
710    unique_update_state_sidecar_path(path, "bak")
711}
712
713fn unique_update_state_sidecar_path(path: &Path, suffix: &str) -> PathBuf {
714    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
715
716    let timestamp = SystemTime::now()
717        .duration_since(UNIX_EPOCH)
718        .unwrap_or_default()
719        .as_nanos();
720    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
721    let file_name = path
722        .file_name()
723        .and_then(|name| name.to_str())
724        .unwrap_or("update_state.json");
725
726    path.with_file_name(format!(
727        ".{file_name}.{suffix}.{}.{}.{}",
728        std::process::id(),
729        timestamp,
730        nonce
731    ))
732}
733
734/// Current unix timestamp
735fn now_unix() -> i64 {
736    i64::try_from(
737        SystemTime::now()
738            .duration_since(UNIX_EPOCH)
739            .unwrap_or_default()
740            .as_secs(),
741    )
742    .unwrap_or(i64::MAX)
743}
744
745// ============================================================================
746// Synchronous API for TUI (blocking HTTP)
747// ============================================================================
748
749/// Synchronous version of `check_for_updates` for use in sync TUI code.
750/// Uses a short-lived asupersync runtime and native HTTP client.
751pub fn check_for_updates_sync(current_version: &str) -> Option<UpdateInfo> {
752    if updates_disabled() {
753        return None;
754    }
755
756    let mut state = UpdateState::load();
757
758    // Respect check interval
759    if !state.should_check() {
760        debug!("update check: skipping, checked recently");
761        return None;
762    }
763
764    // Fetch latest release (blocking)
765    let release = match fetch_latest_release_blocking() {
766        Ok(r) => r,
767        Err(e) => {
768            debug!("update check: fetch failed (offline?): {e}");
769            return None;
770        }
771    };
772
773    let info = build_update_info(current_version, release, &state)?;
774
775    // Persist cadence only after a successful fetch + parse so transient
776    // network or server errors do not suppress future checks for an hour.
777    state.mark_checked();
778    if let Err(e) = state.save() {
779        warn!("update check: failed to save state: {e}");
780    }
781
782    Some(info)
783}
784
785fn build_update_info(
786    current_version: &str,
787    release: GitHubRelease,
788    state: &UpdateState,
789) -> Option<UpdateInfo> {
790    let GitHubRelease { tag_name, html_url } = release;
791    if !is_trusted_release_notes_url(&html_url) {
792        debug!("update check: untrusted release notes URL '{}'", html_url);
793        return None;
794    }
795
796    let (latest_version, latest) = match parse_update_tag(&tag_name) {
797        Some((version, parsed)) => (version.to_string(), parsed),
798        None => {
799            debug!("update check: invalid version tag '{}'", tag_name);
800            return None;
801        }
802    };
803
804    let current = match Version::parse(current_version) {
805        Ok(v) => v,
806        Err(e) => {
807            debug!("update check: invalid current version '{current_version}': {e}");
808            return None;
809        }
810    };
811    let is_skipped = state.is_skipped(&latest_version);
812
813    Some(UpdateInfo {
814        latest_version,
815        tag_name,
816        current_version: current_version.to_string(),
817        release_url: html_url,
818        is_newer: latest > current,
819        is_skipped,
820    })
821}
822
823/// Fetch latest release using the native asupersync HTTP client.
824async fn fetch_latest_release() -> Result<GitHubRelease> {
825    if let Some(handle) = asupersync::runtime::Runtime::current_handle() {
826        let (tx, rx) = std::sync::mpsc::channel();
827
828        handle
829            .try_spawn_with_cx(move |cx| async move {
830                let _ = tx.send(fetch_latest_release_with_cx(&cx).await);
831            })
832            .context("spawning update check task")?;
833
834        loop {
835            match rx.try_recv() {
836                Ok(result) => return result,
837                Err(TryRecvError::Empty) => asupersync::runtime::yield_now().await,
838                Err(TryRecvError::Disconnected) => {
839                    anyhow::bail!("update check task exited before returning a result");
840                }
841            }
842        }
843    }
844
845    let cx = asupersync::Cx::current().context("update check requires an active asupersync Cx")?;
846    fetch_latest_release_with_cx(&cx).await
847}
848
849async fn fetch_latest_release_with_cx(cx: &asupersync::Cx) -> Result<GitHubRelease> {
850    let url = format!("{}/releases/latest", release_api_base_url());
851    let client = asupersync::http::h1::HttpClient::builder()
852        .user_agent(concat!("cass/", env!("CARGO_PKG_VERSION")))
853        .build();
854    let response = asupersync::time::timeout(
855        cx.now(),
856        Duration::from_secs(HTTP_TIMEOUT_SECS),
857        client.request(
858            cx,
859            asupersync::http::h1::Method::Get,
860            &url,
861            vec![(
862                "Accept".to_string(),
863                "application/vnd.github.v3+json".to_string(),
864            )],
865            Vec::new(),
866        ),
867    )
868    .await
869    .map_err(|e| anyhow::anyhow!("timed out fetching release: {e}"))?
870    .context("fetching release")?;
871
872    if !response.is_success() {
873        anyhow::bail!("GitHub API returned {}", response.status);
874    }
875
876    response
877        .json::<GitHubRelease>()
878        .context("parsing release JSON")
879}
880
881/// Fetch latest release using a dedicated synchronous runtime.
882fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
883    asupersync::runtime::RuntimeBuilder::current_thread()
884        .build()
885        .context("building update-check runtime")?
886        .block_on(fetch_latest_release())
887}
888
889/// Start a background thread to check for updates.
890/// Returns a receiver that will contain the result when ready.
891pub fn spawn_update_check(
892    current_version: String,
893) -> std::sync::mpsc::Receiver<Option<UpdateInfo>> {
894    let (tx, rx) = std::sync::mpsc::channel();
895    if updates_disabled() {
896        let _ = tx.send(None);
897        return rx;
898    }
899    std::thread::spawn(move || {
900        let result = check_for_updates_sync(&current_version);
901        let _ = tx.send(result);
902    });
903    rx
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use serial_test::serial;
910
911    #[test]
912    fn test_release_asset_url_uses_immutable_release_downloads() {
913        assert_eq!(
914            release_asset_url("v1.2.3", UNIX_INSTALL_ASSET),
915            format!(
916                "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_ASSET}"
917            )
918        );
919        assert_eq!(
920            release_asset_url("v1.2.3", CHECKSUMS_ASSET),
921            format!("https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET}")
922        );
923        assert_eq!(
924            release_asset_url("v1.2.3", CHECKSUMS_ASSET_ALT),
925            format!(
926                "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET_ALT}"
927            )
928        );
929    }
930
931    #[test]
932    fn test_update_tag_validation_accepts_semver_release_tags() {
933        for tag in [
934            "1.2.3",
935            "v1.2.3",
936            "1.2.3-alpha.1",
937            "v1.2.3-alpha.1",
938            "1.2.3+build.5",
939            "v1.2.3-alpha.1+build.5",
940        ] {
941            assert!(
942                is_valid_update_tag(tag),
943                "expected update tag {tag:?} to be accepted"
944            );
945        }
946    }
947
948    #[test]
949    fn test_update_tag_validation_rejects_non_semver_or_pathlike_tags() {
950        for tag in [
951            "",
952            "v",
953            "..",
954            "v..",
955            "latest",
956            "vlatest",
957            "vv1.2.3",
958            "1.2",
959            "1",
960            "1.2.3/",
961            "1.2.3/../../main",
962            " v1.2.3",
963            "v1.2.3 ",
964        ] {
965            assert!(
966                !is_valid_update_tag(tag),
967                "expected update tag {tag:?} to be rejected"
968            );
969        }
970    }
971
972    #[test]
973    fn test_unix_self_update_verifies_installer_script_before_running() {
974        let script = unix_self_update_script();
975        assert!(script.contains(CHECKSUMS_ASSET));
976        assert!(
977            script.contains(r#"for checksums_url in "$2" "$4"; do"#),
978            "Unix self-update should try both checksum manifest URLs"
979        );
980        assert!(script.contains(r#"expected="$candidate""#));
981        assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
982        assert!(script.contains("sha256sum -c -"));
983        assert!(script.contains("shasum -a 256"));
984        assert!(script.contains("openssl dgst -sha256"));
985        assert!(script.contains(r#"exec bash "$script" --easy-mode --verify --version "$3""#));
986    }
987
988    #[test]
989    fn test_windows_self_update_verifies_installer_script_before_running() {
990        let script = windows_self_update_script();
991        assert!(script.contains(CHECKSUMS_ASSET));
992        assert!(
993            script.contains("foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3]))"),
994            "Windows self-update should try both checksum manifest URLs"
995        );
996        assert!(script.contains("Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums"));
997        assert!(script.contains("if ($Expected)"));
998        assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
999        assert!(script.contains("Get-FileHash"));
1000        assert!(script.contains("-EasyMode -Verify -Version $Version"));
1001        assert!(script.contains("Remove-Item -LiteralPath $Temp"));
1002    }
1003
1004    #[test]
1005    fn test_browser_url_validation_allows_absolute_web_urls() {
1006        assert!(is_browser_url(
1007            "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3"
1008        ));
1009        assert!(is_browser_url("http://localhost:8080/releases/v1.2.3"));
1010        assert!(is_browser_url(
1011            "https://github.com/releases/tag/v1.2.3?asset=install.sh&download=1"
1012        ));
1013    }
1014
1015    #[test]
1016    fn test_browser_url_validation_rejects_non_web_or_relative_urls() {
1017        assert!(!is_browser_url(""));
1018        assert!(!is_browser_url("github.com/releases/tag/v1.2.3"));
1019        assert!(!is_browser_url("file:///etc/passwd"));
1020        assert!(!is_browser_url("javascript:alert(1)"));
1021        assert!(!is_browser_url("data:text/html,<script>alert(1)</script>"));
1022    }
1023
1024    #[test]
1025    fn test_url_validation_rejects_userinfo_credentials() -> Result<(), &'static str> {
1026        for url in [
1027            "https://user:pass@github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3",
1028            "http://user@localhost:8080/releases/v1.2.3",
1029        ] {
1030            if is_browser_url(url) {
1031                return Err("browser URL validation accepted embedded credentials");
1032            }
1033        }
1034
1035        let state = UpdateState::default();
1036        let release = GitHubRelease {
1037            tag_name: "v9.9.9".to_string(),
1038            html_url: format!("https://token@github.com/{GITHUB_REPO}/releases/tag/v9.9.9"),
1039        };
1040        if build_update_info("1.0.0", release, &state).is_some() {
1041            return Err("release metadata accepted embedded credentials");
1042        }
1043
1044        for url in [
1045            "https://token@api.github.com/repos/foo/bar",
1046            "https://token:secret@github.com/Dicklesworthstone/coding_agent_session_search/releases",
1047            "http://user@localhost:8080/api",
1048            "http://user:pass@[::1]:8080/api",
1049        ] {
1050            if is_allowed_update_api_url(url) {
1051                return Err("update API override accepted embedded credentials");
1052            }
1053        }
1054
1055        Ok(())
1056    }
1057
1058    #[test]
1059    fn test_release_info_rejects_untrusted_release_notes_urls() {
1060        let state = UpdateState::default();
1061        let release = GitHubRelease {
1062            tag_name: "v9.9.9".to_string(),
1063            html_url: "https://attacker.example/releases/tag/v9.9.9".to_string(),
1064        };
1065        assert!(
1066            build_update_info("1.0.0", release, &state).is_none(),
1067            "release metadata should not surface non-GitHub release notes URLs"
1068        );
1069
1070        let release = GitHubRelease {
1071            tag_name: "v9.9.9".to_string(),
1072            html_url: "file:///tmp/release-notes.html".to_string(),
1073        };
1074        assert!(
1075            build_update_info("1.0.0", release, &state).is_none(),
1076            "release metadata should not surface non-web URLs"
1077        );
1078
1079        let release = GitHubRelease {
1080            tag_name: "v9.9.9".to_string(),
1081            html_url: "https://github.com/other/project/releases/tag/v9.9.9".to_string(),
1082        };
1083        assert!(
1084            build_update_info("1.0.0", release, &state).is_none(),
1085            "release metadata should not surface unrelated GitHub release notes URLs"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_release_info_rejects_non_semver_release_tags() {
1091        let state = UpdateState::default();
1092        for tag in ["latest", "..", "vv9.9.9"] {
1093            let release = GitHubRelease {
1094                tag_name: tag.to_string(),
1095                html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/{tag}"),
1096            };
1097            assert!(
1098                build_update_info("1.0.0", release, &state).is_none(),
1099                "release metadata should not surface non-SemVer tag {tag:?}"
1100            );
1101        }
1102    }
1103
1104    /// `coding_agent_session_search-87sqx` / `coding_agent_session_search-6bvx8`: the allow-list on
1105    /// `CASS_UPDATE_API_BASE_URL` must reject non-https overrides
1106    /// against non-loopback hosts and non-GitHub HTTPS hosts (malicious .env / shell pollution)
1107    /// while still permitting the `http://127.0.0.1:<port>` form the
1108    /// integration tests below use.
1109    #[test]
1110    fn test_is_allowed_update_api_url_allows_trusted_https_hosts() {
1111        assert!(is_allowed_update_api_url(
1112            "https://api.github.com/repos/foo"
1113        ));
1114        assert!(is_allowed_update_api_url(
1115            "https://api.github.com/repos/bar/baz"
1116        ));
1117        assert!(is_allowed_update_api_url(
1118            "https://github.com/Dicklesworthstone/coding_agent_session_search/releases"
1119        ));
1120    }
1121
1122    #[test]
1123    fn test_is_allowed_update_api_url_rejects_untrusted_https_hosts() {
1124        assert!(!is_allowed_update_api_url("https://attacker.example.com"));
1125        assert!(!is_allowed_update_api_url("https://example.internal"));
1126        assert!(!is_allowed_update_api_url(
1127            "https://api.github.com.attacker.example/repos/foo"
1128        ));
1129        assert!(!is_allowed_update_api_url(
1130            "https://github.com.attacker.example/releases"
1131        ));
1132    }
1133
1134    #[test]
1135    fn test_is_allowed_update_api_url_allows_http_loopback_only() {
1136        assert!(is_allowed_update_api_url("http://127.0.0.1:8080"));
1137        assert!(is_allowed_update_api_url("http://127.0.0.1:45123/api"));
1138        assert!(is_allowed_update_api_url("http://localhost:1234"));
1139        assert!(is_allowed_update_api_url("http://[::1]:8080"));
1140    }
1141
1142    #[test]
1143    fn test_is_allowed_update_api_url_rejects_non_loopback_http() {
1144        assert!(!is_allowed_update_api_url("http://attacker.com"));
1145        assert!(!is_allowed_update_api_url("http://example.com/api"));
1146        // Prefix attack: host must match exactly, not be a prefix
1147        // of a longer attacker-controlled hostname.
1148        assert!(!is_allowed_update_api_url("http://127.0.0.1.attacker.com"));
1149        assert!(!is_allowed_update_api_url("http://localhost.attacker.com"));
1150    }
1151
1152    #[test]
1153    fn test_is_allowed_update_api_url_rejects_other_schemes() {
1154        assert!(!is_allowed_update_api_url("ftp://api.github.com"));
1155        assert!(!is_allowed_update_api_url("file:///etc/passwd"));
1156        assert!(!is_allowed_update_api_url("gopher://example.com"));
1157        assert!(!is_allowed_update_api_url(""));
1158        assert!(!is_allowed_update_api_url("api.github.com"));
1159        // Empty-host https:// — reject so the URL parser doesn't see a
1160        // malformed-but-parseable URL.
1161        assert!(!is_allowed_update_api_url("https://"));
1162        assert!(!is_allowed_update_api_url("https:///path"));
1163    }
1164
1165    #[test]
1166    #[serial]
1167    fn test_state_should_check() {
1168        let mut state = UpdateState::default();
1169        assert!(state.should_check()); // Fresh state should check
1170
1171        state.mark_checked();
1172        assert!(!state.should_check()); // Just checked, should not check again
1173
1174        // Simulate time passing
1175        state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1176        assert!(state.should_check()); // Enough time passed
1177
1178        // Future timestamps should not suppress checks indefinitely after
1179        // clock skew or state-file corruption.
1180        state.last_check_ts = now_unix() + CHECK_INTERVAL_SECS as i64;
1181        assert!(state.should_check());
1182    }
1183
1184    #[test]
1185    #[serial]
1186    fn test_skip_version() {
1187        let mut state = UpdateState::default();
1188        assert!(!state.is_skipped("1.0.0"));
1189
1190        state.skip_version("1.0.0");
1191        assert!(state.is_skipped("1.0.0"));
1192        assert!(!state.is_skipped("1.0.1"));
1193
1194        state.clear_skip();
1195        assert!(!state.is_skipped("1.0.0"));
1196    }
1197
1198    #[test]
1199    #[serial]
1200    fn update_check_state_remains_functional_without_session_dismiss_stub() {
1201        let state = UpdateState::default();
1202        assert!(
1203            state.should_check(),
1204            "fresh state should still trigger checks"
1205        );
1206        assert!(
1207            !state.is_skipped("9.9.9"),
1208            "default state should not invent skipped versions"
1209        );
1210    }
1211
1212    #[test]
1213    #[serial]
1214    fn test_update_info_should_show() {
1215        let info = UpdateInfo {
1216            latest_version: "1.0.0".into(),
1217            tag_name: "v1.0.0".into(),
1218            current_version: "0.9.0".into(),
1219            release_url: "https://example.com".into(),
1220            is_newer: true,
1221            is_skipped: false,
1222        };
1223        assert!(info.should_show());
1224
1225        let skipped = UpdateInfo {
1226            is_skipped: true,
1227            ..info.clone()
1228        };
1229        assert!(!skipped.should_show());
1230
1231        let not_newer = UpdateInfo {
1232            is_newer: false,
1233            ..info
1234        };
1235        assert!(!not_newer.should_show());
1236    }
1237
1238    // =========================================================================
1239    // Upgrade Process Tests
1240    // =========================================================================
1241
1242    #[test]
1243    #[serial]
1244    fn test_version_comparison_upgrade_scenarios() {
1245        // Test various upgrade scenarios with semver comparison
1246        let test_cases = vec![
1247            ("0.1.50", "0.1.52", true, "patch upgrade"),
1248            ("0.1.52", "0.2.0", true, "minor upgrade"),
1249            ("0.1.52", "1.0.0", true, "major upgrade"),
1250            ("0.1.52", "0.1.52", false, "same version"),
1251            ("0.1.52", "0.1.51", false, "downgrade"),
1252            ("0.1.52", "0.1.52-alpha", false, "prerelease is older"),
1253            (
1254                "0.1.52-alpha",
1255                "0.1.52",
1256                true,
1257                "stable is newer than prerelease",
1258            ),
1259        ];
1260
1261        for (current, latest, expected_newer, scenario) in test_cases {
1262            let current_ver = Version::parse(current).expect("valid current version");
1263            let latest_ver = Version::parse(latest).expect("valid latest version");
1264            let is_newer = latest_ver > current_ver;
1265            assert_eq!(
1266                is_newer, expected_newer,
1267                "scenario '{}': {} -> {} should be is_newer={}",
1268                scenario, current, latest, expected_newer
1269            );
1270        }
1271    }
1272
1273    #[test]
1274    #[serial]
1275    fn test_update_state_persistence_round_trip() {
1276        let temp_dir = tempfile::TempDir::new().unwrap();
1277        let state_file = temp_dir.path().join("update_state.json");
1278
1279        // Create state with specific values
1280        let mut state = UpdateState {
1281            last_check_ts: 1234567890,
1282            skipped_version: Some("0.1.50".to_string()),
1283        };
1284
1285        // Write to temp location
1286        let json = serde_json::to_string_pretty(&state).unwrap();
1287        std::fs::write(&state_file, &json).unwrap();
1288
1289        // Read back
1290        let loaded: UpdateState =
1291            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1292
1293        assert_eq!(loaded.last_check_ts, 1234567890);
1294        assert_eq!(loaded.skipped_version, Some("0.1.50".to_string()));
1295        assert!(loaded.is_skipped("0.1.50"));
1296        assert!(!loaded.is_skipped("0.1.51"));
1297
1298        // Modify and save again
1299        state.skip_version("0.1.51");
1300        state.mark_checked();
1301        let json = serde_json::to_string_pretty(&state).unwrap();
1302        std::fs::write(&state_file, &json).unwrap();
1303
1304        let loaded: UpdateState =
1305            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1306        assert!(loaded.is_skipped("0.1.51"));
1307        assert!(!loaded.is_skipped("0.1.50")); // Only latest skip is stored
1308    }
1309
1310    #[cfg(unix)]
1311    fn install_update_state_symlink(data_dir: &std::path::Path) -> (tempfile::TempDir, PathBuf) {
1312        use std::os::unix::fs::symlink;
1313
1314        let outside_dir = tempfile::TempDir::new().unwrap();
1315        let target_file = outside_dir.path().join("target-update-state.json");
1316        std::fs::write(&target_file, "untouched").unwrap();
1317        symlink(&target_file, data_dir.join("update_state.json")).unwrap();
1318        (outside_dir, target_file)
1319    }
1320
1321    #[cfg(unix)]
1322    fn assert_update_state_symlink_was_replaced(
1323        data_dir: &std::path::Path,
1324        target_file: &std::path::Path,
1325        expected_ts: i64,
1326    ) {
1327        let state_file = data_dir.join("update_state.json");
1328        assert_eq!(
1329            std::fs::read_to_string(target_file).unwrap(),
1330            "untouched",
1331            "update state persistence must not follow an existing symlink"
1332        );
1333        assert!(
1334            !std::fs::symlink_metadata(&state_file)
1335                .unwrap()
1336                .file_type()
1337                .is_symlink(),
1338            "state path should be replaced with a regular JSON file"
1339        );
1340
1341        let loaded: UpdateState =
1342            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1343        assert_eq!(loaded.last_check_ts, expected_ts);
1344        assert_eq!(loaded.skipped_version, Some("0.2.0".to_string()));
1345    }
1346
1347    #[cfg(unix)]
1348    #[test]
1349    #[serial]
1350    fn test_update_state_save_replaces_existing_symlink() {
1351        let temp_dir = tempfile::TempDir::new().unwrap();
1352        let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1353        unsafe {
1354            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1355        }
1356
1357        let state = UpdateState {
1358            last_check_ts: 42,
1359            skipped_version: Some("0.2.0".to_string()),
1360        };
1361        state.save().unwrap();
1362
1363        unsafe {
1364            std::env::remove_var("CASS_DATA_DIR");
1365        }
1366        assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 42);
1367    }
1368
1369    #[cfg(unix)]
1370    #[test]
1371    #[serial]
1372    fn test_update_state_save_async_replaces_existing_symlink() {
1373        let temp_dir = tempfile::TempDir::new().unwrap();
1374        let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1375        unsafe {
1376            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1377        }
1378
1379        let state = UpdateState {
1380            last_check_ts: 43,
1381            skipped_version: Some("0.2.0".to_string()),
1382        };
1383        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1384            .build()
1385            .expect("build test runtime");
1386        runtime.block_on(state.save_async()).unwrap();
1387
1388        unsafe {
1389            std::env::remove_var("CASS_DATA_DIR");
1390        }
1391        assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 43);
1392    }
1393
1394    #[test]
1395    #[serial]
1396    fn test_update_info_upgrade_workflow() {
1397        // Simulate the full upgrade decision workflow
1398
1399        // Case 1: New version available, not skipped -> should show
1400        let info = UpdateInfo {
1401            latest_version: "0.2.0".into(),
1402            tag_name: "v0.2.0".into(),
1403            current_version: "0.1.52".into(),
1404            release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0".into(),
1405            is_newer: true,
1406            is_skipped: false,
1407        };
1408        assert!(info.should_show(), "should show upgrade banner");
1409        assert!(info.is_newer, "should detect newer version");
1410
1411        // Case 2: User skips this version
1412        let mut state = UpdateState::default();
1413        state.skip_version(&info.latest_version);
1414        assert!(state.is_skipped(&info.latest_version));
1415
1416        // Now the info should not show (simulating re-check)
1417        let info_after_skip = UpdateInfo {
1418            is_skipped: state.is_skipped(&info.latest_version),
1419            ..info.clone()
1420        };
1421        assert!(
1422            !info_after_skip.should_show(),
1423            "should not show banner for skipped version"
1424        );
1425
1426        // Case 3: New version beyond skipped -> should show again
1427        state.clear_skip();
1428        let newer_info = UpdateInfo {
1429            latest_version: "0.3.0".into(),
1430            tag_name: "v0.3.0".into(),
1431            current_version: "0.1.52".into(),
1432            release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0".into(),
1433            is_newer: true,
1434            is_skipped: false,
1435        };
1436        assert!(
1437            newer_info.should_show(),
1438            "should show banner for version newer than skipped"
1439        );
1440    }
1441
1442    #[test]
1443    #[serial]
1444    fn test_check_interval_respects_cadence() {
1445        let mut state = UpdateState::default();
1446
1447        // Fresh state should check
1448        assert!(state.should_check());
1449
1450        // After checking, should not check again immediately
1451        state.mark_checked();
1452        assert!(!state.should_check());
1453
1454        // After half the interval, still should not check
1455        state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1456        assert!(!state.should_check());
1457
1458        // After full interval, should check again
1459        state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1460        assert!(state.should_check());
1461    }
1462
1463    #[test]
1464    #[serial]
1465    fn test_github_repo_constant_is_valid() {
1466        // Verify the repo constant is properly formatted
1467        assert!(GITHUB_REPO.contains('/'));
1468        let parts: Vec<&str> = GITHUB_REPO.split('/').collect();
1469        assert_eq!(parts.len(), 2, "should be owner/repo format");
1470        assert!(!parts[0].is_empty(), "owner should not be empty");
1471        assert!(!parts[1].is_empty(), "repo should not be empty");
1472        assert_eq!(parts[0], "Dicklesworthstone");
1473        assert_eq!(parts[1], "coding_agent_session_search");
1474    }
1475
1476    // =========================================================================
1477    // Integration Tests with Local HTTP Server (br-e3ze)
1478    // Tests real HTTP client behavior against ephemeral local servers
1479    // =========================================================================
1480
1481    /// Helper to create a simple HTTP response
1482    fn http_response(status: u16, body: &str) -> String {
1483        format!(
1484            "HTTP/1.1 {} {}\r\n\
1485             Content-Type: application/json\r\n\
1486             Content-Length: {}\r\n\
1487             Connection: close\r\n\
1488             \r\n\
1489             {}",
1490            status,
1491            match status {
1492                200 => "OK",
1493                404 => "Not Found",
1494                500 => "Internal Server Error",
1495                _ => "Unknown",
1496            },
1497            body.len(),
1498            body
1499        )
1500    }
1501
1502    /// Start a simple HTTP server on an ephemeral port that serves a single response
1503    fn start_test_server(
1504        response_body: &str,
1505        status: u16,
1506    ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1507        use std::io::{ErrorKind, Read, Write};
1508        use std::net::Shutdown;
1509        use std::net::TcpListener;
1510        use std::time::{Duration, Instant};
1511
1512        let listener = TcpListener::bind("127.0.0.1:0").expect("bind to ephemeral port");
1513        let addr = listener.local_addr().expect("get local addr");
1514        let _ = listener.set_nonblocking(true);
1515
1516        let response = http_response(status, response_body);
1517
1518        let handle = std::thread::spawn(move || {
1519            let deadline = Instant::now() + Duration::from_secs(2);
1520            let mut stream = loop {
1521                match listener.accept() {
1522                    Ok((stream, _)) => break stream,
1523                    Err(err)
1524                        if err.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
1525                    {
1526                        std::thread::sleep(Duration::from_millis(5));
1527                    }
1528                    Err(_) => return,
1529                }
1530            };
1531
1532            let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1533            let mut buf = [0u8; 4096];
1534            match stream.read(&mut buf) {
1535                Ok(_) => {}
1536                Err(err)
1537                    if matches!(
1538                        err.kind(),
1539                        ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::UnexpectedEof
1540                    ) => {}
1541                Err(_) => return,
1542            }
1543
1544            if stream.write_all(response.as_bytes()).is_ok() {
1545                let _ = stream.flush();
1546                let _ = stream.shutdown(Shutdown::Both);
1547            }
1548        });
1549
1550        // Small delay to ensure server is ready
1551        std::thread::sleep(std::time::Duration::from_millis(10));
1552
1553        (addr, handle)
1554    }
1555
1556    #[test]
1557    #[serial]
1558    fn integration_fetch_release_success() {
1559        // Start local server with valid release JSON
1560        let release_json = r#"{
1561            "tag_name": "v0.2.0",
1562            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1563        }"#;
1564
1565        let (addr, handle) = start_test_server(release_json, 200);
1566
1567        // Set env var to point to our local server
1568        // Safety: Tests run sequentially in same process, but this is still racy
1569        // We use a unique port each time so it's safe for our purposes
1570        unsafe {
1571            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1572        }
1573
1574        // Make the request using blocking client
1575        let result = fetch_latest_release_blocking();
1576
1577        // Clean up env var
1578        unsafe {
1579            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1580        }
1581
1582        handle.join().expect("server thread");
1583
1584        let release = result.expect("fetch should succeed");
1585        assert_eq!(release.tag_name, "v0.2.0");
1586        assert!(release.html_url.contains("v0.2.0"));
1587    }
1588
1589    #[test]
1590    #[serial]
1591    fn integration_fetch_release_404_error() {
1592        let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1593
1594        unsafe {
1595            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1596        }
1597
1598        let result = fetch_latest_release_blocking();
1599
1600        unsafe {
1601            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1602        }
1603
1604        handle.join().expect("server thread");
1605
1606        assert!(result.is_err(), "should return error for 404");
1607        let err = result.unwrap_err();
1608        assert!(
1609            err.to_string().contains("404") || err.to_string().contains("Not Found"),
1610            "error should mention 404: {}",
1611            err
1612        );
1613    }
1614
1615    #[test]
1616    #[serial]
1617    fn integration_fetch_release_malformed_json() {
1618        let (addr, handle) = start_test_server("this is not json", 200);
1619
1620        unsafe {
1621            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1622        }
1623
1624        let result = fetch_latest_release_blocking();
1625
1626        unsafe {
1627            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1628        }
1629
1630        handle.join().expect("server thread");
1631
1632        assert!(result.is_err(), "should return error for malformed JSON");
1633    }
1634
1635    #[test]
1636    #[serial]
1637    fn integration_fetch_release_missing_fields() {
1638        // JSON that doesn't have required fields
1639        let incomplete_json = r#"{"some_other_field": "value"}"#;
1640
1641        let (addr, handle) = start_test_server(incomplete_json, 200);
1642
1643        unsafe {
1644            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1645        }
1646
1647        let result = fetch_latest_release_blocking();
1648
1649        unsafe {
1650            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1651        }
1652
1653        handle.join().expect("server thread");
1654
1655        // Should fail to parse because tag_name is missing
1656        assert!(result.is_err(), "should error on missing required fields");
1657    }
1658
1659    #[test]
1660    #[serial]
1661    fn integration_fetch_release_server_error() {
1662        let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1663
1664        unsafe {
1665            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1666        }
1667
1668        let result = fetch_latest_release_blocking();
1669
1670        unsafe {
1671            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1672        }
1673
1674        handle.join().expect("server thread");
1675
1676        assert!(result.is_err(), "should return error for 500");
1677    }
1678
1679    #[test]
1680    #[serial]
1681    fn integration_version_comparison_with_real_fetch() {
1682        // Test the full flow: fetch -> parse -> compare
1683        let release_json = r#"{
1684            "tag_name": "v0.3.0",
1685            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1686        }"#;
1687
1688        let (addr, handle) = start_test_server(release_json, 200);
1689
1690        unsafe {
1691            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1692        }
1693
1694        let result = fetch_latest_release_blocking();
1695
1696        unsafe {
1697            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1698        }
1699
1700        handle.join().expect("server thread");
1701
1702        let release = result.expect("fetch should succeed");
1703
1704        // Parse and compare versions like the real code does
1705        let latest_str = release.tag_name.trim_start_matches('v');
1706        let latest = Version::parse(latest_str).expect("parse latest version");
1707        let current = Version::parse("0.1.50").expect("parse current version");
1708
1709        assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1710    }
1711
1712    #[test]
1713    #[serial]
1714    fn integration_prerelease_version_handling() {
1715        // Test handling of pre-release versions from server
1716        let release_json = r#"{
1717            "tag_name": "v0.2.0-beta.1",
1718            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1719        }"#;
1720
1721        let (addr, handle) = start_test_server(release_json, 200);
1722
1723        unsafe {
1724            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1725        }
1726
1727        let result = fetch_latest_release_blocking();
1728
1729        unsafe {
1730            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1731        }
1732
1733        handle.join().expect("server thread");
1734
1735        let release = result.expect("fetch should succeed");
1736        let latest_str = release.tag_name.trim_start_matches('v');
1737        let latest = Version::parse(latest_str).expect("parse prerelease version");
1738
1739        // Prerelease 0.2.0-beta.1 should be less than 0.2.0
1740        let stable = Version::parse("0.2.0").expect("parse stable version");
1741        assert!(
1742            latest < stable,
1743            "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1744        );
1745
1746        // But newer than 0.1.50
1747        let older = Version::parse("0.1.50").expect("parse older version");
1748        assert!(
1749            latest > older,
1750            "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1751        );
1752    }
1753
1754    #[test]
1755    #[serial]
1756    fn integration_connection_refused_is_offline_friendly() {
1757        // Point to a port that's not listening
1758        unsafe {
1759            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1760        }
1761
1762        let result = fetch_latest_release_blocking();
1763
1764        unsafe {
1765            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1766        }
1767
1768        // Should fail gracefully, not panic
1769        assert!(
1770            result.is_err(),
1771            "should return error when server unreachable"
1772        );
1773        // The error is wrapped in context, so check the full chain
1774        let err = result.unwrap_err();
1775        let err_chain = format!("{:?}", err).to_lowercase();
1776        assert!(
1777            err_chain.contains("connection")
1778                || err_chain.contains("connect")
1779                || err_chain.contains("refused")
1780                || err_chain.contains("fetch")
1781                || err_chain.contains("os error"),
1782            "should be a network/fetch error: {}",
1783            err_chain
1784        );
1785    }
1786
1787    #[test]
1788    #[serial]
1789    fn integration_failed_sync_check_does_not_throttle_future_checks() {
1790        let temp_dir = tempfile::TempDir::new().unwrap();
1791        let state_file = temp_dir.path().join("update_state.json");
1792        unsafe {
1793            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1794            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1795            std::env::remove_var("CASS_SKIP_UPDATE");
1796            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1797            std::env::remove_var("TUI_HEADLESS");
1798            std::env::remove_var("CI");
1799        }
1800
1801        let result = check_for_updates_sync("0.1.0");
1802        assert!(result.is_none(), "offline sync check should fail quietly");
1803
1804        assert!(
1805            !state_file.exists(),
1806            "failed sync checks must not persist cadence state"
1807        );
1808
1809        unsafe {
1810            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1811            std::env::remove_var("CASS_DATA_DIR");
1812        }
1813    }
1814
1815    #[test]
1816    #[serial]
1817    fn integration_failed_async_check_does_not_throttle_future_checks() {
1818        let temp_dir = tempfile::TempDir::new().unwrap();
1819        let state_file = temp_dir.path().join("update_state.json");
1820        unsafe {
1821            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1822            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1823            std::env::remove_var("CASS_SKIP_UPDATE");
1824            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1825            std::env::remove_var("TUI_HEADLESS");
1826            std::env::remove_var("CI");
1827        }
1828
1829        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1830            .build()
1831            .expect("build test runtime");
1832        let result = runtime.block_on(check_for_updates("0.1.0"));
1833        assert!(result.is_none(), "offline async check should fail quietly");
1834
1835        assert!(
1836            !state_file.exists(),
1837            "failed async checks must not persist cadence state"
1838        );
1839
1840        unsafe {
1841            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1842            std::env::remove_var("CASS_DATA_DIR");
1843        }
1844    }
1845
1846    #[cfg(unix)]
1847    #[test]
1848    #[serial]
1849    fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1850        use std::os::unix::fs::PermissionsExt;
1851
1852        let temp_dir = tempfile::TempDir::new().unwrap();
1853        let state_file = temp_dir.path().join("update_state.json");
1854        let state = UpdateState {
1855            last_check_ts: now_unix(),
1856            skipped_version: None,
1857        };
1858        std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1859
1860        let release_json = r#"{
1861            "tag_name": "v9.9.9",
1862            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1863        }"#;
1864        let (addr, handle) = start_test_server(release_json, 200);
1865
1866        let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1867        let file_metadata = std::fs::metadata(&state_file).unwrap();
1868        let dir_mode = dir_metadata.permissions().mode();
1869        let file_mode = file_metadata.permissions().mode();
1870
1871        let mut readonly_dir = dir_metadata.permissions();
1872        readonly_dir.set_mode(0o555);
1873        std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1874
1875        let mut readonly_file = file_metadata.permissions();
1876        readonly_file.set_mode(0o444);
1877        std::fs::set_permissions(&state_file, readonly_file).unwrap();
1878
1879        unsafe {
1880            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1881            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1882            std::env::remove_var("CASS_SKIP_UPDATE");
1883            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1884            std::env::remove_var("TUI_HEADLESS");
1885            std::env::remove_var("CI");
1886        }
1887
1888        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1889            .build()
1890            .expect("build test runtime");
1891        let result = runtime.block_on(force_check("0.1.0"));
1892
1893        let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1894        restore_file.set_mode(file_mode);
1895        std::fs::set_permissions(&state_file, restore_file).unwrap();
1896
1897        let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1898        restore_dir.set_mode(dir_mode);
1899        std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1900
1901        unsafe {
1902            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1903            std::env::remove_var("CASS_DATA_DIR");
1904        }
1905
1906        handle.join().expect("server thread");
1907
1908        let info = result.expect("force check should bypass cadence and succeed");
1909        assert_eq!(info.latest_version, "9.9.9");
1910        assert!(info.is_newer);
1911    }
1912
1913    #[test]
1914    #[serial]
1915    fn integration_blocking_fetch_release_success_v1() {
1916        // Validates the synchronous wrapper over the native HTTP client.
1917        let release_json = r#"{
1918            "tag_name": "v1.0.0",
1919            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1920        }"#;
1921
1922        let (addr, handle) = start_test_server(release_json, 200);
1923
1924        unsafe {
1925            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1926        }
1927
1928        let result = fetch_latest_release_blocking();
1929
1930        unsafe {
1931            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1932        }
1933
1934        handle.join().expect("server thread");
1935
1936        let release = result.expect("blocking fetch should succeed");
1937        assert_eq!(release.tag_name, "v1.0.0");
1938    }
1939
1940    #[test]
1941    #[serial]
1942    fn integration_blocking_fetch_release_403_error() {
1943        let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1944
1945        unsafe {
1946            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1947        }
1948
1949        let result = fetch_latest_release_blocking();
1950
1951        unsafe {
1952            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1953        }
1954
1955        handle.join().expect("server thread");
1956
1957        assert!(result.is_err(), "should error on 403");
1958    }
1959
1960    #[test]
1961    #[serial]
1962    fn integration_release_api_base_url_default() {
1963        // When env var is not set, should use GitHub API
1964        unsafe {
1965            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1966        }
1967
1968        let url = release_api_base_url();
1969        assert!(
1970            url.contains("api.github.com"),
1971            "default should use GitHub API"
1972        );
1973        assert!(
1974            url.contains(GITHUB_REPO),
1975            "default should include repo path"
1976        );
1977    }
1978
1979    #[test]
1980    #[serial]
1981    fn integration_release_api_base_url_override() {
1982        let custom_url = "http://localhost:8080/api";
1983        unsafe {
1984            std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1985        }
1986
1987        let url = release_api_base_url();
1988
1989        unsafe {
1990            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1991        }
1992
1993        assert_eq!(url, custom_url, "should use custom URL from env var");
1994    }
1995
1996    #[test]
1997    #[serial]
1998    fn integration_http_timeout_is_reasonable() {
1999        const _: () = {
2000            // Verify the timeout constant is short enough for startup
2001            assert!(
2002                HTTP_TIMEOUT_SECS <= 10,
2003                "HTTP timeout should be short to avoid blocking startup"
2004            );
2005            assert!(
2006                HTTP_TIMEOUT_SECS >= 3,
2007                "HTTP timeout should be long enough for slow networks"
2008            );
2009        };
2010    }
2011
2012    #[test]
2013    #[serial]
2014    fn integration_check_interval_is_reasonable() {
2015        const _: () = {
2016            // Verify check interval is reasonable (not too frequent, not too rare)
2017            assert!(
2018                CHECK_INTERVAL_SECS >= 3600,
2019                "should not check more than once per hour"
2020            );
2021            assert!(
2022                CHECK_INTERVAL_SECS <= 86400,
2023                "should check at least once per day"
2024            );
2025        };
2026    }
2027}