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::TcpListener;
1509        use std::sync::mpsc;
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        let (ready_tx, ready_rx) = mpsc::channel();
1518
1519        let handle = std::thread::spawn(move || {
1520            let _ = ready_tx.send(());
1521            let deadline = Instant::now() + Duration::from_secs(2);
1522            let mut stream = loop {
1523                match listener.accept() {
1524                    Ok((stream, _)) => break stream,
1525                    Err(err)
1526                        if err.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
1527                    {
1528                        std::thread::sleep(Duration::from_millis(5));
1529                    }
1530                    Err(_) => return,
1531                }
1532            };
1533
1534            let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1535            let mut buf = [0u8; 4096];
1536            match stream.read(&mut buf) {
1537                Ok(_) => {}
1538                Err(err)
1539                    if matches!(
1540                        err.kind(),
1541                        ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::UnexpectedEof
1542                    ) => {}
1543                Err(_) => return,
1544            }
1545
1546            if stream.write_all(response.as_bytes()).is_ok() {
1547                let _ = stream.flush();
1548                std::thread::sleep(Duration::from_millis(25));
1549            }
1550        });
1551
1552        let _ = ready_rx.recv_timeout(std::time::Duration::from_secs(1));
1553
1554        (addr, handle)
1555    }
1556
1557    #[test]
1558    #[serial]
1559    fn integration_fetch_release_success() {
1560        // Start local server with valid release JSON
1561        let release_json = r#"{
1562            "tag_name": "v0.2.0",
1563            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1564        }"#;
1565
1566        let (addr, handle) = start_test_server(release_json, 200);
1567
1568        // Set env var to point to our local server
1569        // Safety: Tests run sequentially in same process, but this is still racy
1570        // We use a unique port each time so it's safe for our purposes
1571        unsafe {
1572            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1573        }
1574
1575        // Make the request using blocking client
1576        let result = fetch_latest_release_blocking();
1577
1578        // Clean up env var
1579        unsafe {
1580            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1581        }
1582
1583        handle.join().expect("server thread");
1584
1585        let release = result.expect("fetch should succeed");
1586        assert_eq!(release.tag_name, "v0.2.0");
1587        assert!(release.html_url.contains("v0.2.0"));
1588    }
1589
1590    #[test]
1591    #[serial]
1592    fn integration_fetch_release_404_error() {
1593        let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
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        assert!(result.is_err(), "should return error for 404");
1608        let err = result.unwrap_err();
1609        assert!(
1610            err.to_string().contains("404") || err.to_string().contains("Not Found"),
1611            "error should mention 404: {}",
1612            err
1613        );
1614    }
1615
1616    #[test]
1617    #[serial]
1618    fn integration_fetch_release_malformed_json() {
1619        let (addr, handle) = start_test_server("this is not json", 200);
1620
1621        unsafe {
1622            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1623        }
1624
1625        let result = fetch_latest_release_blocking();
1626
1627        unsafe {
1628            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1629        }
1630
1631        handle.join().expect("server thread");
1632
1633        assert!(result.is_err(), "should return error for malformed JSON");
1634    }
1635
1636    #[test]
1637    #[serial]
1638    fn integration_fetch_release_missing_fields() {
1639        // JSON that doesn't have required fields
1640        let incomplete_json = r#"{"some_other_field": "value"}"#;
1641
1642        let (addr, handle) = start_test_server(incomplete_json, 200);
1643
1644        unsafe {
1645            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1646        }
1647
1648        let result = fetch_latest_release_blocking();
1649
1650        unsafe {
1651            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1652        }
1653
1654        handle.join().expect("server thread");
1655
1656        // Should fail to parse because tag_name is missing
1657        assert!(result.is_err(), "should error on missing required fields");
1658    }
1659
1660    #[test]
1661    #[serial]
1662    fn integration_fetch_release_server_error() {
1663        let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1664
1665        unsafe {
1666            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1667        }
1668
1669        let result = fetch_latest_release_blocking();
1670
1671        unsafe {
1672            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1673        }
1674
1675        handle.join().expect("server thread");
1676
1677        assert!(result.is_err(), "should return error for 500");
1678    }
1679
1680    #[test]
1681    #[serial]
1682    fn integration_version_comparison_with_real_fetch() {
1683        // Test the full flow: fetch -> parse -> compare
1684        let release_json = r#"{
1685            "tag_name": "v0.3.0",
1686            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1687        }"#;
1688
1689        let (addr, handle) = start_test_server(release_json, 200);
1690
1691        unsafe {
1692            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1693        }
1694
1695        let result = fetch_latest_release_blocking();
1696
1697        unsafe {
1698            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1699        }
1700
1701        handle.join().expect("server thread");
1702
1703        let release = result.expect("fetch should succeed");
1704
1705        // Parse and compare versions like the real code does
1706        let latest_str = release.tag_name.trim_start_matches('v');
1707        let latest = Version::parse(latest_str).expect("parse latest version");
1708        let current = Version::parse("0.1.50").expect("parse current version");
1709
1710        assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1711    }
1712
1713    #[test]
1714    #[serial]
1715    fn integration_prerelease_version_handling() {
1716        // Test handling of pre-release versions from server
1717        let release_json = r#"{
1718            "tag_name": "v0.2.0-beta.1",
1719            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1720        }"#;
1721
1722        let (addr, handle) = start_test_server(release_json, 200);
1723
1724        unsafe {
1725            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1726        }
1727
1728        let result = fetch_latest_release_blocking();
1729
1730        unsafe {
1731            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1732        }
1733
1734        handle.join().expect("server thread");
1735
1736        let release = result.expect("fetch should succeed");
1737        let latest_str = release.tag_name.trim_start_matches('v');
1738        let latest = Version::parse(latest_str).expect("parse prerelease version");
1739
1740        // Prerelease 0.2.0-beta.1 should be less than 0.2.0
1741        let stable = Version::parse("0.2.0").expect("parse stable version");
1742        assert!(
1743            latest < stable,
1744            "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1745        );
1746
1747        // But newer than 0.1.50
1748        let older = Version::parse("0.1.50").expect("parse older version");
1749        assert!(
1750            latest > older,
1751            "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1752        );
1753    }
1754
1755    #[test]
1756    #[serial]
1757    fn integration_connection_refused_is_offline_friendly() {
1758        // Point to a port that's not listening
1759        unsafe {
1760            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1761        }
1762
1763        let result = fetch_latest_release_blocking();
1764
1765        unsafe {
1766            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1767        }
1768
1769        // Should fail gracefully, not panic
1770        assert!(
1771            result.is_err(),
1772            "should return error when server unreachable"
1773        );
1774        // The error is wrapped in context, so check the full chain
1775        let err = result.unwrap_err();
1776        let err_chain = format!("{:?}", err).to_lowercase();
1777        assert!(
1778            err_chain.contains("connection")
1779                || err_chain.contains("connect")
1780                || err_chain.contains("refused")
1781                || err_chain.contains("fetch")
1782                || err_chain.contains("os error"),
1783            "should be a network/fetch error: {}",
1784            err_chain
1785        );
1786    }
1787
1788    #[test]
1789    #[serial]
1790    fn integration_failed_sync_check_does_not_throttle_future_checks() {
1791        let temp_dir = tempfile::TempDir::new().unwrap();
1792        let state_file = temp_dir.path().join("update_state.json");
1793        unsafe {
1794            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1795            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1796            std::env::remove_var("CASS_SKIP_UPDATE");
1797            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1798            std::env::remove_var("TUI_HEADLESS");
1799            std::env::remove_var("CI");
1800        }
1801
1802        let result = check_for_updates_sync("0.1.0");
1803        assert!(result.is_none(), "offline sync check should fail quietly");
1804
1805        assert!(
1806            !state_file.exists(),
1807            "failed sync checks must not persist cadence state"
1808        );
1809
1810        unsafe {
1811            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1812            std::env::remove_var("CASS_DATA_DIR");
1813        }
1814    }
1815
1816    #[test]
1817    #[serial]
1818    fn integration_failed_async_check_does_not_throttle_future_checks() {
1819        let temp_dir = tempfile::TempDir::new().unwrap();
1820        let state_file = temp_dir.path().join("update_state.json");
1821        unsafe {
1822            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1823            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1824            std::env::remove_var("CASS_SKIP_UPDATE");
1825            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1826            std::env::remove_var("TUI_HEADLESS");
1827            std::env::remove_var("CI");
1828        }
1829
1830        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1831            .build()
1832            .expect("build test runtime");
1833        let result = runtime.block_on(check_for_updates("0.1.0"));
1834        assert!(result.is_none(), "offline async check should fail quietly");
1835
1836        assert!(
1837            !state_file.exists(),
1838            "failed async checks must not persist cadence state"
1839        );
1840
1841        unsafe {
1842            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1843            std::env::remove_var("CASS_DATA_DIR");
1844        }
1845    }
1846
1847    #[cfg(unix)]
1848    #[test]
1849    #[serial]
1850    fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1851        use std::os::unix::fs::PermissionsExt;
1852
1853        let temp_dir = tempfile::TempDir::new().unwrap();
1854        let state_file = temp_dir.path().join("update_state.json");
1855        let state = UpdateState {
1856            last_check_ts: now_unix(),
1857            skipped_version: None,
1858        };
1859        std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1860
1861        let release_json = r#"{
1862            "tag_name": "v9.9.9",
1863            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1864        }"#;
1865        let (addr, handle) = start_test_server(release_json, 200);
1866
1867        let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1868        let file_metadata = std::fs::metadata(&state_file).unwrap();
1869        let dir_mode = dir_metadata.permissions().mode();
1870        let file_mode = file_metadata.permissions().mode();
1871
1872        let mut readonly_dir = dir_metadata.permissions();
1873        readonly_dir.set_mode(0o555);
1874        std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1875
1876        let mut readonly_file = file_metadata.permissions();
1877        readonly_file.set_mode(0o444);
1878        std::fs::set_permissions(&state_file, readonly_file).unwrap();
1879
1880        unsafe {
1881            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1882            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1883            std::env::remove_var("CASS_SKIP_UPDATE");
1884            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1885            std::env::remove_var("TUI_HEADLESS");
1886            std::env::remove_var("CI");
1887        }
1888
1889        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1890            .build()
1891            .expect("build test runtime");
1892        let result = runtime.block_on(force_check("0.1.0"));
1893
1894        let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1895        restore_file.set_mode(file_mode);
1896        std::fs::set_permissions(&state_file, restore_file).unwrap();
1897
1898        let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1899        restore_dir.set_mode(dir_mode);
1900        std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1901
1902        unsafe {
1903            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1904            std::env::remove_var("CASS_DATA_DIR");
1905        }
1906
1907        handle.join().expect("server thread");
1908
1909        let info = result.expect("force check should bypass cadence and succeed");
1910        assert_eq!(info.latest_version, "9.9.9");
1911        assert!(info.is_newer);
1912    }
1913
1914    #[test]
1915    #[serial]
1916    fn integration_blocking_fetch_release_success_v1() {
1917        // Validates the synchronous wrapper over the native HTTP client.
1918        let release_json = r#"{
1919            "tag_name": "v1.0.0",
1920            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1921        }"#;
1922
1923        let (addr, handle) = start_test_server(release_json, 200);
1924
1925        unsafe {
1926            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1927        }
1928
1929        let result = fetch_latest_release_blocking();
1930
1931        unsafe {
1932            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1933        }
1934
1935        handle.join().expect("server thread");
1936
1937        let release = result.expect("blocking fetch should succeed");
1938        assert_eq!(release.tag_name, "v1.0.0");
1939    }
1940
1941    #[test]
1942    #[serial]
1943    fn integration_blocking_fetch_release_403_error() {
1944        let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1945
1946        unsafe {
1947            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1948        }
1949
1950        let result = fetch_latest_release_blocking();
1951
1952        unsafe {
1953            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1954        }
1955
1956        handle.join().expect("server thread");
1957
1958        assert!(result.is_err(), "should error on 403");
1959    }
1960
1961    #[test]
1962    #[serial]
1963    fn integration_release_api_base_url_default() {
1964        // When env var is not set, should use GitHub API
1965        unsafe {
1966            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1967        }
1968
1969        let url = release_api_base_url();
1970        assert!(
1971            url.contains("api.github.com"),
1972            "default should use GitHub API"
1973        );
1974        assert!(
1975            url.contains(GITHUB_REPO),
1976            "default should include repo path"
1977        );
1978    }
1979
1980    #[test]
1981    #[serial]
1982    fn integration_release_api_base_url_override() {
1983        let custom_url = "http://localhost:8080/api";
1984        unsafe {
1985            std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1986        }
1987
1988        let url = release_api_base_url();
1989
1990        unsafe {
1991            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1992        }
1993
1994        assert_eq!(url, custom_url, "should use custom URL from env var");
1995    }
1996
1997    #[test]
1998    #[serial]
1999    fn integration_http_timeout_is_reasonable() {
2000        const _: () = {
2001            // Verify the timeout constant is short enough for startup
2002            assert!(
2003                HTTP_TIMEOUT_SECS <= 10,
2004                "HTTP timeout should be short to avoid blocking startup"
2005            );
2006            assert!(
2007                HTTP_TIMEOUT_SECS >= 3,
2008                "HTTP timeout should be long enough for slow networks"
2009            );
2010        };
2011    }
2012
2013    #[test]
2014    #[serial]
2015    fn integration_check_interval_is_reasonable() {
2016        const _: () = {
2017            // Verify check interval is reasonable (not too frequent, not too rare)
2018            assert!(
2019                CHECK_INTERVAL_SECS >= 3600,
2020                "should not check more than once per hour"
2021            );
2022            assert!(
2023                CHECK_INTERVAL_SECS <= 86400,
2024                "should check at least once per day"
2025            );
2026        };
2027    }
2028}