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(cx) = asupersync::Cx::current() {
826        return fetch_latest_release_with_cx(&cx).await;
827    }
828
829    let handle = asupersync::runtime::Runtime::current_handle()
830        .context("update check requires an active asupersync runtime")?;
831    let (tx, rx) = std::sync::mpsc::channel();
832
833    handle
834        .try_spawn_with_cx(move |cx| async move {
835            let _ = tx.send(fetch_latest_release_with_cx(&cx).await);
836        })
837        .context("spawning update check task")?;
838
839    loop {
840        match rx.try_recv() {
841            Ok(result) => return result,
842            Err(TryRecvError::Empty) => asupersync::runtime::yield_now().await,
843            Err(TryRecvError::Disconnected) => {
844                anyhow::bail!("update check task exited before returning a result");
845            }
846        }
847    }
848}
849
850async fn fetch_latest_release_with_cx(cx: &asupersync::Cx) -> Result<GitHubRelease> {
851    let url = format!("{}/releases/latest", release_api_base_url());
852    let client = asupersync::http::h1::HttpClient::builder()
853        .user_agent(concat!("cass/", env!("CARGO_PKG_VERSION")))
854        .build();
855    let response = asupersync::time::timeout(
856        cx.now(),
857        Duration::from_secs(HTTP_TIMEOUT_SECS),
858        client.request(
859            cx,
860            asupersync::http::h1::Method::Get,
861            &url,
862            vec![(
863                "Accept".to_string(),
864                "application/vnd.github.v3+json".to_string(),
865            )],
866            Vec::new(),
867        ),
868    )
869    .await
870    .map_err(|e| anyhow::anyhow!("timed out fetching release: {e}"))?
871    .context("fetching release")?;
872
873    if !response.is_success() {
874        anyhow::bail!("GitHub API returned {}", response.status);
875    }
876
877    response
878        .json::<GitHubRelease>()
879        .context("parsing release JSON")
880}
881
882/// Fetch latest release using a dedicated synchronous runtime.
883fn fetch_latest_release_blocking() -> Result<GitHubRelease> {
884    asupersync::runtime::RuntimeBuilder::current_thread()
885        .build()
886        .context("building update-check runtime")?
887        .block_on(fetch_latest_release())
888}
889
890/// Start a background thread to check for updates.
891/// Returns a receiver that will contain the result when ready.
892pub fn spawn_update_check(
893    current_version: String,
894) -> std::sync::mpsc::Receiver<Option<UpdateInfo>> {
895    let (tx, rx) = std::sync::mpsc::channel();
896    if updates_disabled() {
897        let _ = tx.send(None);
898        return rx;
899    }
900    std::thread::spawn(move || {
901        let result = check_for_updates_sync(&current_version);
902        let _ = tx.send(result);
903    });
904    rx
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use serial_test::serial;
911
912    #[test]
913    fn test_release_asset_url_uses_immutable_release_downloads() {
914        assert_eq!(
915            release_asset_url("v1.2.3", UNIX_INSTALL_ASSET),
916            format!(
917                "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{UNIX_INSTALL_ASSET}"
918            )
919        );
920        assert_eq!(
921            release_asset_url("v1.2.3", CHECKSUMS_ASSET),
922            format!("https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET}")
923        );
924        assert_eq!(
925            release_asset_url("v1.2.3", CHECKSUMS_ASSET_ALT),
926            format!(
927                "https://github.com/{GITHUB_REPO}/releases/download/v1.2.3/{CHECKSUMS_ASSET_ALT}"
928            )
929        );
930    }
931
932    #[test]
933    fn test_update_tag_validation_accepts_semver_release_tags() {
934        for tag in [
935            "1.2.3",
936            "v1.2.3",
937            "1.2.3-alpha.1",
938            "v1.2.3-alpha.1",
939            "1.2.3+build.5",
940            "v1.2.3-alpha.1+build.5",
941        ] {
942            assert!(
943                is_valid_update_tag(tag),
944                "expected update tag {tag:?} to be accepted"
945            );
946        }
947    }
948
949    #[test]
950    fn test_update_tag_validation_rejects_non_semver_or_pathlike_tags() {
951        for tag in [
952            "",
953            "v",
954            "..",
955            "v..",
956            "latest",
957            "vlatest",
958            "vv1.2.3",
959            "1.2",
960            "1",
961            "1.2.3/",
962            "1.2.3/../../main",
963            " v1.2.3",
964            "v1.2.3 ",
965        ] {
966            assert!(
967                !is_valid_update_tag(tag),
968                "expected update tag {tag:?} to be rejected"
969            );
970        }
971    }
972
973    #[test]
974    fn test_unix_self_update_verifies_installer_script_before_running() {
975        let script = unix_self_update_script();
976        assert!(script.contains(CHECKSUMS_ASSET));
977        assert!(
978            script.contains(r#"for checksums_url in "$2" "$4"; do"#),
979            "Unix self-update should try both checksum manifest URLs"
980        );
981        assert!(script.contains(r#"expected="$candidate""#));
982        assert!(script.contains(&format!(r#"$2 == "{UNIX_INSTALL_ASSET}""#)));
983        assert!(script.contains("sha256sum -c -"));
984        assert!(script.contains("shasum -a 256"));
985        assert!(script.contains("openssl dgst -sha256"));
986        assert!(script.contains(r#"exec bash "$script" --easy-mode --verify --version "$3""#));
987    }
988
989    #[test]
990    fn test_windows_self_update_verifies_installer_script_before_running() {
991        let script = windows_self_update_script();
992        assert!(script.contains(CHECKSUMS_ASSET));
993        assert!(
994            script.contains("foreach ($ChecksumsCandidateUrl in @($ChecksumsUrl, $args[3]))"),
995            "Windows self-update should try both checksum manifest URLs"
996        );
997        assert!(script.contains("Invoke-WebRequest -Uri $ChecksumsCandidateUrl -OutFile $Sums"));
998        assert!(script.contains("if ($Expected)"));
999        assert!(script.contains(&format!(r#"$Parts[1] -eq "{WINDOWS_INSTALL_ASSET}""#)));
1000        assert!(script.contains("Get-FileHash"));
1001        assert!(script.contains("-EasyMode -Verify -Version $Version"));
1002        assert!(script.contains("Remove-Item -LiteralPath $Temp"));
1003    }
1004
1005    #[test]
1006    fn test_browser_url_validation_allows_absolute_web_urls() {
1007        assert!(is_browser_url(
1008            "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3"
1009        ));
1010        assert!(is_browser_url("http://localhost:8080/releases/v1.2.3"));
1011        assert!(is_browser_url(
1012            "https://github.com/releases/tag/v1.2.3?asset=install.sh&download=1"
1013        ));
1014    }
1015
1016    #[test]
1017    fn test_browser_url_validation_rejects_non_web_or_relative_urls() {
1018        assert!(!is_browser_url(""));
1019        assert!(!is_browser_url("github.com/releases/tag/v1.2.3"));
1020        assert!(!is_browser_url("file:///etc/passwd"));
1021        assert!(!is_browser_url("javascript:alert(1)"));
1022        assert!(!is_browser_url("data:text/html,<script>alert(1)</script>"));
1023    }
1024
1025    #[test]
1026    fn test_url_validation_rejects_userinfo_credentials() -> Result<(), &'static str> {
1027        for url in [
1028            "https://user:pass@github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.2.3",
1029            "http://user@localhost:8080/releases/v1.2.3",
1030        ] {
1031            if is_browser_url(url) {
1032                return Err("browser URL validation accepted embedded credentials");
1033            }
1034        }
1035
1036        let state = UpdateState::default();
1037        let release = GitHubRelease {
1038            tag_name: "v9.9.9".to_string(),
1039            html_url: format!("https://token@github.com/{GITHUB_REPO}/releases/tag/v9.9.9"),
1040        };
1041        if build_update_info("1.0.0", release, &state).is_some() {
1042            return Err("release metadata accepted embedded credentials");
1043        }
1044
1045        for url in [
1046            "https://token@api.github.com/repos/foo/bar",
1047            "https://token:secret@github.com/Dicklesworthstone/coding_agent_session_search/releases",
1048            "http://user@localhost:8080/api",
1049            "http://user:pass@[::1]:8080/api",
1050        ] {
1051            if is_allowed_update_api_url(url) {
1052                return Err("update API override accepted embedded credentials");
1053            }
1054        }
1055
1056        Ok(())
1057    }
1058
1059    #[test]
1060    fn test_release_info_rejects_untrusted_release_notes_urls() {
1061        let state = UpdateState::default();
1062        let release = GitHubRelease {
1063            tag_name: "v9.9.9".to_string(),
1064            html_url: "https://attacker.example/releases/tag/v9.9.9".to_string(),
1065        };
1066        assert!(
1067            build_update_info("1.0.0", release, &state).is_none(),
1068            "release metadata should not surface non-GitHub release notes URLs"
1069        );
1070
1071        let release = GitHubRelease {
1072            tag_name: "v9.9.9".to_string(),
1073            html_url: "file:///tmp/release-notes.html".to_string(),
1074        };
1075        assert!(
1076            build_update_info("1.0.0", release, &state).is_none(),
1077            "release metadata should not surface non-web URLs"
1078        );
1079
1080        let release = GitHubRelease {
1081            tag_name: "v9.9.9".to_string(),
1082            html_url: "https://github.com/other/project/releases/tag/v9.9.9".to_string(),
1083        };
1084        assert!(
1085            build_update_info("1.0.0", release, &state).is_none(),
1086            "release metadata should not surface unrelated GitHub release notes URLs"
1087        );
1088    }
1089
1090    #[test]
1091    fn test_release_info_rejects_non_semver_release_tags() {
1092        let state = UpdateState::default();
1093        for tag in ["latest", "..", "vv9.9.9"] {
1094            let release = GitHubRelease {
1095                tag_name: tag.to_string(),
1096                html_url: format!("https://github.com/{GITHUB_REPO}/releases/tag/{tag}"),
1097            };
1098            assert!(
1099                build_update_info("1.0.0", release, &state).is_none(),
1100                "release metadata should not surface non-SemVer tag {tag:?}"
1101            );
1102        }
1103    }
1104
1105    /// `coding_agent_session_search-87sqx` / `coding_agent_session_search-6bvx8`: the allow-list on
1106    /// `CASS_UPDATE_API_BASE_URL` must reject non-https overrides
1107    /// against non-loopback hosts and non-GitHub HTTPS hosts (malicious .env / shell pollution)
1108    /// while still permitting the `http://127.0.0.1:<port>` form the
1109    /// integration tests below use.
1110    #[test]
1111    fn test_is_allowed_update_api_url_allows_trusted_https_hosts() {
1112        assert!(is_allowed_update_api_url(
1113            "https://api.github.com/repos/foo"
1114        ));
1115        assert!(is_allowed_update_api_url(
1116            "https://api.github.com/repos/bar/baz"
1117        ));
1118        assert!(is_allowed_update_api_url(
1119            "https://github.com/Dicklesworthstone/coding_agent_session_search/releases"
1120        ));
1121    }
1122
1123    #[test]
1124    fn test_is_allowed_update_api_url_rejects_untrusted_https_hosts() {
1125        assert!(!is_allowed_update_api_url("https://attacker.example.com"));
1126        assert!(!is_allowed_update_api_url("https://example.internal"));
1127        assert!(!is_allowed_update_api_url(
1128            "https://api.github.com.attacker.example/repos/foo"
1129        ));
1130        assert!(!is_allowed_update_api_url(
1131            "https://github.com.attacker.example/releases"
1132        ));
1133    }
1134
1135    #[test]
1136    fn test_is_allowed_update_api_url_allows_http_loopback_only() {
1137        assert!(is_allowed_update_api_url("http://127.0.0.1:8080"));
1138        assert!(is_allowed_update_api_url("http://127.0.0.1:45123/api"));
1139        assert!(is_allowed_update_api_url("http://localhost:1234"));
1140        assert!(is_allowed_update_api_url("http://[::1]:8080"));
1141    }
1142
1143    #[test]
1144    fn test_is_allowed_update_api_url_rejects_non_loopback_http() {
1145        assert!(!is_allowed_update_api_url("http://attacker.com"));
1146        assert!(!is_allowed_update_api_url("http://example.com/api"));
1147        // Prefix attack: host must match exactly, not be a prefix
1148        // of a longer attacker-controlled hostname.
1149        assert!(!is_allowed_update_api_url("http://127.0.0.1.attacker.com"));
1150        assert!(!is_allowed_update_api_url("http://localhost.attacker.com"));
1151    }
1152
1153    #[test]
1154    fn test_is_allowed_update_api_url_rejects_other_schemes() {
1155        assert!(!is_allowed_update_api_url("ftp://api.github.com"));
1156        assert!(!is_allowed_update_api_url("file:///etc/passwd"));
1157        assert!(!is_allowed_update_api_url("gopher://example.com"));
1158        assert!(!is_allowed_update_api_url(""));
1159        assert!(!is_allowed_update_api_url("api.github.com"));
1160        // Empty-host https:// — reject so the URL parser doesn't see a
1161        // malformed-but-parseable URL.
1162        assert!(!is_allowed_update_api_url("https://"));
1163        assert!(!is_allowed_update_api_url("https:///path"));
1164    }
1165
1166    #[test]
1167    #[serial]
1168    fn test_state_should_check() {
1169        let mut state = UpdateState::default();
1170        assert!(state.should_check()); // Fresh state should check
1171
1172        state.mark_checked();
1173        assert!(!state.should_check()); // Just checked, should not check again
1174
1175        // Simulate time passing
1176        state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1177        assert!(state.should_check()); // Enough time passed
1178
1179        // Future timestamps should not suppress checks indefinitely after
1180        // clock skew or state-file corruption.
1181        state.last_check_ts = now_unix() + CHECK_INTERVAL_SECS as i64;
1182        assert!(state.should_check());
1183    }
1184
1185    #[test]
1186    #[serial]
1187    fn test_skip_version() {
1188        let mut state = UpdateState::default();
1189        assert!(!state.is_skipped("1.0.0"));
1190
1191        state.skip_version("1.0.0");
1192        assert!(state.is_skipped("1.0.0"));
1193        assert!(!state.is_skipped("1.0.1"));
1194
1195        state.clear_skip();
1196        assert!(!state.is_skipped("1.0.0"));
1197    }
1198
1199    #[test]
1200    #[serial]
1201    fn update_check_state_remains_functional_without_session_dismiss_stub() {
1202        let state = UpdateState::default();
1203        assert!(
1204            state.should_check(),
1205            "fresh state should still trigger checks"
1206        );
1207        assert!(
1208            !state.is_skipped("9.9.9"),
1209            "default state should not invent skipped versions"
1210        );
1211    }
1212
1213    #[test]
1214    #[serial]
1215    fn test_update_info_should_show() {
1216        let info = UpdateInfo {
1217            latest_version: "1.0.0".into(),
1218            tag_name: "v1.0.0".into(),
1219            current_version: "0.9.0".into(),
1220            release_url: "https://example.com".into(),
1221            is_newer: true,
1222            is_skipped: false,
1223        };
1224        assert!(info.should_show());
1225
1226        let skipped = UpdateInfo {
1227            is_skipped: true,
1228            ..info.clone()
1229        };
1230        assert!(!skipped.should_show());
1231
1232        let not_newer = UpdateInfo {
1233            is_newer: false,
1234            ..info
1235        };
1236        assert!(!not_newer.should_show());
1237    }
1238
1239    // =========================================================================
1240    // Upgrade Process Tests
1241    // =========================================================================
1242
1243    #[test]
1244    #[serial]
1245    fn test_version_comparison_upgrade_scenarios() {
1246        // Test various upgrade scenarios with semver comparison
1247        let test_cases = vec![
1248            ("0.1.50", "0.1.52", true, "patch upgrade"),
1249            ("0.1.52", "0.2.0", true, "minor upgrade"),
1250            ("0.1.52", "1.0.0", true, "major upgrade"),
1251            ("0.1.52", "0.1.52", false, "same version"),
1252            ("0.1.52", "0.1.51", false, "downgrade"),
1253            ("0.1.52", "0.1.52-alpha", false, "prerelease is older"),
1254            (
1255                "0.1.52-alpha",
1256                "0.1.52",
1257                true,
1258                "stable is newer than prerelease",
1259            ),
1260        ];
1261
1262        for (current, latest, expected_newer, scenario) in test_cases {
1263            let current_ver = Version::parse(current).expect("valid current version");
1264            let latest_ver = Version::parse(latest).expect("valid latest version");
1265            let is_newer = latest_ver > current_ver;
1266            assert_eq!(
1267                is_newer, expected_newer,
1268                "scenario '{}': {} -> {} should be is_newer={}",
1269                scenario, current, latest, expected_newer
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    #[serial]
1276    fn test_update_state_persistence_round_trip() {
1277        let temp_dir = tempfile::TempDir::new().unwrap();
1278        let state_file = temp_dir.path().join("update_state.json");
1279
1280        // Create state with specific values
1281        let mut state = UpdateState {
1282            last_check_ts: 1234567890,
1283            skipped_version: Some("0.1.50".to_string()),
1284        };
1285
1286        // Write to temp location
1287        let json = serde_json::to_string_pretty(&state).unwrap();
1288        std::fs::write(&state_file, &json).unwrap();
1289
1290        // Read back
1291        let loaded: UpdateState =
1292            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1293
1294        assert_eq!(loaded.last_check_ts, 1234567890);
1295        assert_eq!(loaded.skipped_version, Some("0.1.50".to_string()));
1296        assert!(loaded.is_skipped("0.1.50"));
1297        assert!(!loaded.is_skipped("0.1.51"));
1298
1299        // Modify and save again
1300        state.skip_version("0.1.51");
1301        state.mark_checked();
1302        let json = serde_json::to_string_pretty(&state).unwrap();
1303        std::fs::write(&state_file, &json).unwrap();
1304
1305        let loaded: UpdateState =
1306            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1307        assert!(loaded.is_skipped("0.1.51"));
1308        assert!(!loaded.is_skipped("0.1.50")); // Only latest skip is stored
1309    }
1310
1311    #[cfg(unix)]
1312    fn install_update_state_symlink(data_dir: &std::path::Path) -> (tempfile::TempDir, PathBuf) {
1313        use std::os::unix::fs::symlink;
1314
1315        let outside_dir = tempfile::TempDir::new().unwrap();
1316        let target_file = outside_dir.path().join("target-update-state.json");
1317        std::fs::write(&target_file, "untouched").unwrap();
1318        symlink(&target_file, data_dir.join("update_state.json")).unwrap();
1319        (outside_dir, target_file)
1320    }
1321
1322    #[cfg(unix)]
1323    fn assert_update_state_symlink_was_replaced(
1324        data_dir: &std::path::Path,
1325        target_file: &std::path::Path,
1326        expected_ts: i64,
1327    ) {
1328        let state_file = data_dir.join("update_state.json");
1329        assert_eq!(
1330            std::fs::read_to_string(target_file).unwrap(),
1331            "untouched",
1332            "update state persistence must not follow an existing symlink"
1333        );
1334        assert!(
1335            !std::fs::symlink_metadata(&state_file)
1336                .unwrap()
1337                .file_type()
1338                .is_symlink(),
1339            "state path should be replaced with a regular JSON file"
1340        );
1341
1342        let loaded: UpdateState =
1343            serde_json::from_str(&std::fs::read_to_string(&state_file).unwrap()).unwrap();
1344        assert_eq!(loaded.last_check_ts, expected_ts);
1345        assert_eq!(loaded.skipped_version, Some("0.2.0".to_string()));
1346    }
1347
1348    #[cfg(unix)]
1349    #[test]
1350    #[serial]
1351    fn test_update_state_save_replaces_existing_symlink() {
1352        let temp_dir = tempfile::TempDir::new().unwrap();
1353        let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1354        unsafe {
1355            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1356        }
1357
1358        let state = UpdateState {
1359            last_check_ts: 42,
1360            skipped_version: Some("0.2.0".to_string()),
1361        };
1362        state.save().unwrap();
1363
1364        unsafe {
1365            std::env::remove_var("CASS_DATA_DIR");
1366        }
1367        assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 42);
1368    }
1369
1370    #[cfg(unix)]
1371    #[test]
1372    #[serial]
1373    fn test_update_state_save_async_replaces_existing_symlink() {
1374        let temp_dir = tempfile::TempDir::new().unwrap();
1375        let (_outside_dir, target_file) = install_update_state_symlink(temp_dir.path());
1376        unsafe {
1377            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1378        }
1379
1380        let state = UpdateState {
1381            last_check_ts: 43,
1382            skipped_version: Some("0.2.0".to_string()),
1383        };
1384        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1385            .build()
1386            .expect("build test runtime");
1387        runtime.block_on(state.save_async()).unwrap();
1388
1389        unsafe {
1390            std::env::remove_var("CASS_DATA_DIR");
1391        }
1392        assert_update_state_symlink_was_replaced(temp_dir.path(), &target_file, 43);
1393    }
1394
1395    #[test]
1396    #[serial]
1397    fn test_update_info_upgrade_workflow() {
1398        // Simulate the full upgrade decision workflow
1399
1400        // Case 1: New version available, not skipped -> should show
1401        let info = UpdateInfo {
1402            latest_version: "0.2.0".into(),
1403            tag_name: "v0.2.0".into(),
1404            current_version: "0.1.52".into(),
1405            release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0".into(),
1406            is_newer: true,
1407            is_skipped: false,
1408        };
1409        assert!(info.should_show(), "should show upgrade banner");
1410        assert!(info.is_newer, "should detect newer version");
1411
1412        // Case 2: User skips this version
1413        let mut state = UpdateState::default();
1414        state.skip_version(&info.latest_version);
1415        assert!(state.is_skipped(&info.latest_version));
1416
1417        // Now the info should not show (simulating re-check)
1418        let info_after_skip = UpdateInfo {
1419            is_skipped: state.is_skipped(&info.latest_version),
1420            ..info.clone()
1421        };
1422        assert!(
1423            !info_after_skip.should_show(),
1424            "should not show banner for skipped version"
1425        );
1426
1427        // Case 3: New version beyond skipped -> should show again
1428        state.clear_skip();
1429        let newer_info = UpdateInfo {
1430            latest_version: "0.3.0".into(),
1431            tag_name: "v0.3.0".into(),
1432            current_version: "0.1.52".into(),
1433            release_url: "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0".into(),
1434            is_newer: true,
1435            is_skipped: false,
1436        };
1437        assert!(
1438            newer_info.should_show(),
1439            "should show banner for version newer than skipped"
1440        );
1441    }
1442
1443    #[test]
1444    #[serial]
1445    fn test_check_interval_respects_cadence() {
1446        let mut state = UpdateState::default();
1447
1448        // Fresh state should check
1449        assert!(state.should_check());
1450
1451        // After checking, should not check again immediately
1452        state.mark_checked();
1453        assert!(!state.should_check());
1454
1455        // After half the interval, still should not check
1456        state.last_check_ts = now_unix() - (CHECK_INTERVAL_SECS as i64 / 2);
1457        assert!(!state.should_check());
1458
1459        // After full interval, should check again
1460        state.last_check_ts = now_unix() - CHECK_INTERVAL_SECS as i64 - 1;
1461        assert!(state.should_check());
1462    }
1463
1464    #[test]
1465    #[serial]
1466    fn test_github_repo_constant_is_valid() {
1467        // Verify the repo constant is properly formatted
1468        assert!(GITHUB_REPO.contains('/'));
1469        let parts: Vec<&str> = GITHUB_REPO.split('/').collect();
1470        assert_eq!(parts.len(), 2, "should be owner/repo format");
1471        assert!(!parts[0].is_empty(), "owner should not be empty");
1472        assert!(!parts[1].is_empty(), "repo should not be empty");
1473        assert_eq!(parts[0], "Dicklesworthstone");
1474        assert_eq!(parts[1], "coding_agent_session_search");
1475    }
1476
1477    // =========================================================================
1478    // Integration Tests with Local HTTP Server (br-e3ze)
1479    // Tests real HTTP client behavior against ephemeral local servers
1480    // =========================================================================
1481
1482    /// Helper to create a simple HTTP response
1483    fn http_response(status: u16, body: &str) -> String {
1484        format!(
1485            "HTTP/1.1 {} {}\r\n\
1486             Content-Type: application/json\r\n\
1487             Content-Length: {}\r\n\
1488             Connection: close\r\n\
1489             \r\n\
1490             {}",
1491            status,
1492            match status {
1493                200 => "OK",
1494                404 => "Not Found",
1495                500 => "Internal Server Error",
1496                _ => "Unknown",
1497            },
1498            body.len(),
1499            body
1500        )
1501    }
1502
1503    /// Start a simple HTTP server on an ephemeral port that serves a single response
1504    fn start_test_server(
1505        response_body: &str,
1506        status: u16,
1507    ) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
1508        use std::io::{Read, Write};
1509        use std::net::TcpListener;
1510
1511        let listener = TcpListener::bind("127.0.0.1:0").expect("bind to ephemeral port");
1512        let addr = listener.local_addr().expect("get local addr");
1513
1514        let response = http_response(status, response_body);
1515
1516        let handle = std::thread::spawn(move || {
1517            // Accept one connection and respond
1518            if let Ok((mut stream, _)) = listener.accept() {
1519                let mut buf = [0u8; 1024];
1520                let _ = stream.read(&mut buf);
1521                let _ = stream.write_all(response.as_bytes());
1522                let _ = stream.flush();
1523            }
1524        });
1525
1526        // Small delay to ensure server is ready
1527        std::thread::sleep(std::time::Duration::from_millis(10));
1528
1529        (addr, handle)
1530    }
1531
1532    #[test]
1533    #[serial]
1534    fn integration_fetch_release_success() {
1535        // Start local server with valid release JSON
1536        let release_json = r#"{
1537            "tag_name": "v0.2.0",
1538            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0"
1539        }"#;
1540
1541        let (addr, handle) = start_test_server(release_json, 200);
1542
1543        // Set env var to point to our local server
1544        // Safety: Tests run sequentially in same process, but this is still racy
1545        // We use a unique port each time so it's safe for our purposes
1546        unsafe {
1547            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1548        }
1549
1550        // Make the request using blocking client
1551        let result = fetch_latest_release_blocking();
1552
1553        // Clean up env var
1554        unsafe {
1555            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1556        }
1557
1558        handle.join().expect("server thread");
1559
1560        let release = result.expect("fetch should succeed");
1561        assert_eq!(release.tag_name, "v0.2.0");
1562        assert!(release.html_url.contains("v0.2.0"));
1563    }
1564
1565    #[test]
1566    #[serial]
1567    fn integration_fetch_release_404_error() {
1568        let (addr, handle) = start_test_server(r#"{"message": "Not Found"}"#, 404);
1569
1570        unsafe {
1571            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1572        }
1573
1574        let result = fetch_latest_release_blocking();
1575
1576        unsafe {
1577            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1578        }
1579
1580        handle.join().expect("server thread");
1581
1582        assert!(result.is_err(), "should return error for 404");
1583        let err = result.unwrap_err();
1584        assert!(
1585            err.to_string().contains("404") || err.to_string().contains("Not Found"),
1586            "error should mention 404: {}",
1587            err
1588        );
1589    }
1590
1591    #[test]
1592    #[serial]
1593    fn integration_fetch_release_malformed_json() {
1594        let (addr, handle) = start_test_server("this is not json", 200);
1595
1596        unsafe {
1597            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1598        }
1599
1600        let result = fetch_latest_release_blocking();
1601
1602        unsafe {
1603            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1604        }
1605
1606        handle.join().expect("server thread");
1607
1608        assert!(result.is_err(), "should return error for malformed JSON");
1609    }
1610
1611    #[test]
1612    #[serial]
1613    fn integration_fetch_release_missing_fields() {
1614        // JSON that doesn't have required fields
1615        let incomplete_json = r#"{"some_other_field": "value"}"#;
1616
1617        let (addr, handle) = start_test_server(incomplete_json, 200);
1618
1619        unsafe {
1620            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1621        }
1622
1623        let result = fetch_latest_release_blocking();
1624
1625        unsafe {
1626            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1627        }
1628
1629        handle.join().expect("server thread");
1630
1631        // Should fail to parse because tag_name is missing
1632        assert!(result.is_err(), "should error on missing required fields");
1633    }
1634
1635    #[test]
1636    #[serial]
1637    fn integration_fetch_release_server_error() {
1638        let (addr, handle) = start_test_server(r#"{"error": "Internal error"}"#, 500);
1639
1640        unsafe {
1641            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1642        }
1643
1644        let result = fetch_latest_release_blocking();
1645
1646        unsafe {
1647            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1648        }
1649
1650        handle.join().expect("server thread");
1651
1652        assert!(result.is_err(), "should return error for 500");
1653    }
1654
1655    #[test]
1656    #[serial]
1657    fn integration_version_comparison_with_real_fetch() {
1658        // Test the full flow: fetch -> parse -> compare
1659        let release_json = r#"{
1660            "tag_name": "v0.3.0",
1661            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.3.0"
1662        }"#;
1663
1664        let (addr, handle) = start_test_server(release_json, 200);
1665
1666        unsafe {
1667            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1668        }
1669
1670        let result = fetch_latest_release_blocking();
1671
1672        unsafe {
1673            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1674        }
1675
1676        handle.join().expect("server thread");
1677
1678        let release = result.expect("fetch should succeed");
1679
1680        // Parse and compare versions like the real code does
1681        let latest_str = release.tag_name.trim_start_matches('v');
1682        let latest = Version::parse(latest_str).expect("parse latest version");
1683        let current = Version::parse("0.1.50").expect("parse current version");
1684
1685        assert!(latest > current, "0.3.0 should be newer than 0.1.50");
1686    }
1687
1688    #[test]
1689    #[serial]
1690    fn integration_prerelease_version_handling() {
1691        // Test handling of pre-release versions from server
1692        let release_json = r#"{
1693            "tag_name": "v0.2.0-beta.1",
1694            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v0.2.0-beta.1"
1695        }"#;
1696
1697        let (addr, handle) = start_test_server(release_json, 200);
1698
1699        unsafe {
1700            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1701        }
1702
1703        let result = fetch_latest_release_blocking();
1704
1705        unsafe {
1706            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1707        }
1708
1709        handle.join().expect("server thread");
1710
1711        let release = result.expect("fetch should succeed");
1712        let latest_str = release.tag_name.trim_start_matches('v');
1713        let latest = Version::parse(latest_str).expect("parse prerelease version");
1714
1715        // Prerelease 0.2.0-beta.1 should be less than 0.2.0
1716        let stable = Version::parse("0.2.0").expect("parse stable version");
1717        assert!(
1718            latest < stable,
1719            "prerelease 0.2.0-beta.1 should be older than stable 0.2.0"
1720        );
1721
1722        // But newer than 0.1.50
1723        let older = Version::parse("0.1.50").expect("parse older version");
1724        assert!(
1725            latest > older,
1726            "prerelease 0.2.0-beta.1 should be newer than 0.1.50"
1727        );
1728    }
1729
1730    #[test]
1731    #[serial]
1732    fn integration_connection_refused_is_offline_friendly() {
1733        // Point to a port that's not listening
1734        unsafe {
1735            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1736        }
1737
1738        let result = fetch_latest_release_blocking();
1739
1740        unsafe {
1741            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1742        }
1743
1744        // Should fail gracefully, not panic
1745        assert!(
1746            result.is_err(),
1747            "should return error when server unreachable"
1748        );
1749        // The error is wrapped in context, so check the full chain
1750        let err = result.unwrap_err();
1751        let err_chain = format!("{:?}", err).to_lowercase();
1752        assert!(
1753            err_chain.contains("connection")
1754                || err_chain.contains("connect")
1755                || err_chain.contains("refused")
1756                || err_chain.contains("fetch")
1757                || err_chain.contains("os error"),
1758            "should be a network/fetch error: {}",
1759            err_chain
1760        );
1761    }
1762
1763    #[test]
1764    #[serial]
1765    fn integration_failed_sync_check_does_not_throttle_future_checks() {
1766        let temp_dir = tempfile::TempDir::new().unwrap();
1767        let state_file = temp_dir.path().join("update_state.json");
1768        unsafe {
1769            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1770            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1771            std::env::remove_var("CASS_SKIP_UPDATE");
1772            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1773            std::env::remove_var("TUI_HEADLESS");
1774            std::env::remove_var("CI");
1775        }
1776
1777        let result = check_for_updates_sync("0.1.0");
1778        assert!(result.is_none(), "offline sync check should fail quietly");
1779
1780        assert!(
1781            !state_file.exists(),
1782            "failed sync checks must not persist cadence state"
1783        );
1784
1785        unsafe {
1786            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1787            std::env::remove_var("CASS_DATA_DIR");
1788        }
1789    }
1790
1791    #[test]
1792    #[serial]
1793    fn integration_failed_async_check_does_not_throttle_future_checks() {
1794        let temp_dir = tempfile::TempDir::new().unwrap();
1795        let state_file = temp_dir.path().join("update_state.json");
1796        unsafe {
1797            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1798            std::env::set_var("CASS_UPDATE_API_BASE_URL", "http://127.0.0.1:1");
1799            std::env::remove_var("CASS_SKIP_UPDATE");
1800            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1801            std::env::remove_var("TUI_HEADLESS");
1802            std::env::remove_var("CI");
1803        }
1804
1805        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1806            .build()
1807            .expect("build test runtime");
1808        let result = runtime.block_on(check_for_updates("0.1.0"));
1809        assert!(result.is_none(), "offline async check should fail quietly");
1810
1811        assert!(
1812            !state_file.exists(),
1813            "failed async checks must not persist cadence state"
1814        );
1815
1816        unsafe {
1817            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1818            std::env::remove_var("CASS_DATA_DIR");
1819        }
1820    }
1821
1822    #[cfg(unix)]
1823    #[test]
1824    #[serial]
1825    fn integration_force_check_bypasses_cadence_even_when_state_save_fails() {
1826        use std::os::unix::fs::PermissionsExt;
1827
1828        let temp_dir = tempfile::TempDir::new().unwrap();
1829        let state_file = temp_dir.path().join("update_state.json");
1830        let state = UpdateState {
1831            last_check_ts: now_unix(),
1832            skipped_version: None,
1833        };
1834        std::fs::write(&state_file, serde_json::to_string_pretty(&state).unwrap()).unwrap();
1835
1836        let release_json = r#"{
1837            "tag_name": "v9.9.9",
1838            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v9.9.9"
1839        }"#;
1840        let (addr, handle) = start_test_server(release_json, 200);
1841
1842        let dir_metadata = std::fs::metadata(temp_dir.path()).unwrap();
1843        let file_metadata = std::fs::metadata(&state_file).unwrap();
1844        let dir_mode = dir_metadata.permissions().mode();
1845        let file_mode = file_metadata.permissions().mode();
1846
1847        let mut readonly_dir = dir_metadata.permissions();
1848        readonly_dir.set_mode(0o555);
1849        std::fs::set_permissions(temp_dir.path(), readonly_dir).unwrap();
1850
1851        let mut readonly_file = file_metadata.permissions();
1852        readonly_file.set_mode(0o444);
1853        std::fs::set_permissions(&state_file, readonly_file).unwrap();
1854
1855        unsafe {
1856            std::env::set_var("CASS_DATA_DIR", temp_dir.path());
1857            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1858            std::env::remove_var("CASS_SKIP_UPDATE");
1859            std::env::remove_var("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT");
1860            std::env::remove_var("TUI_HEADLESS");
1861            std::env::remove_var("CI");
1862        }
1863
1864        let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
1865            .build()
1866            .expect("build test runtime");
1867        let result = runtime.block_on(force_check("0.1.0"));
1868
1869        let mut restore_file = std::fs::metadata(&state_file).unwrap().permissions();
1870        restore_file.set_mode(file_mode);
1871        std::fs::set_permissions(&state_file, restore_file).unwrap();
1872
1873        let mut restore_dir = std::fs::metadata(temp_dir.path()).unwrap().permissions();
1874        restore_dir.set_mode(dir_mode);
1875        std::fs::set_permissions(temp_dir.path(), restore_dir).unwrap();
1876
1877        unsafe {
1878            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1879            std::env::remove_var("CASS_DATA_DIR");
1880        }
1881
1882        handle.join().expect("server thread");
1883
1884        let info = result.expect("force check should bypass cadence and succeed");
1885        assert_eq!(info.latest_version, "9.9.9");
1886        assert!(info.is_newer);
1887    }
1888
1889    #[test]
1890    #[serial]
1891    fn integration_blocking_fetch_release_success_v1() {
1892        // Validates the synchronous wrapper over the native HTTP client.
1893        let release_json = r#"{
1894            "tag_name": "v1.0.0",
1895            "html_url": "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/tag/v1.0.0"
1896        }"#;
1897
1898        let (addr, handle) = start_test_server(release_json, 200);
1899
1900        unsafe {
1901            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1902        }
1903
1904        let result = fetch_latest_release_blocking();
1905
1906        unsafe {
1907            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1908        }
1909
1910        handle.join().expect("server thread");
1911
1912        let release = result.expect("blocking fetch should succeed");
1913        assert_eq!(release.tag_name, "v1.0.0");
1914    }
1915
1916    #[test]
1917    #[serial]
1918    fn integration_blocking_fetch_release_403_error() {
1919        let (addr, handle) = start_test_server(r#"{"error": "forbidden"}"#, 403);
1920
1921        unsafe {
1922            std::env::set_var("CASS_UPDATE_API_BASE_URL", format!("http://{}", addr));
1923        }
1924
1925        let result = fetch_latest_release_blocking();
1926
1927        unsafe {
1928            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1929        }
1930
1931        handle.join().expect("server thread");
1932
1933        assert!(result.is_err(), "should error on 403");
1934    }
1935
1936    #[test]
1937    #[serial]
1938    fn integration_release_api_base_url_default() {
1939        // When env var is not set, should use GitHub API
1940        unsafe {
1941            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1942        }
1943
1944        let url = release_api_base_url();
1945        assert!(
1946            url.contains("api.github.com"),
1947            "default should use GitHub API"
1948        );
1949        assert!(
1950            url.contains(GITHUB_REPO),
1951            "default should include repo path"
1952        );
1953    }
1954
1955    #[test]
1956    #[serial]
1957    fn integration_release_api_base_url_override() {
1958        let custom_url = "http://localhost:8080/api";
1959        unsafe {
1960            std::env::set_var("CASS_UPDATE_API_BASE_URL", custom_url);
1961        }
1962
1963        let url = release_api_base_url();
1964
1965        unsafe {
1966            std::env::remove_var("CASS_UPDATE_API_BASE_URL");
1967        }
1968
1969        assert_eq!(url, custom_url, "should use custom URL from env var");
1970    }
1971
1972    #[test]
1973    #[serial]
1974    fn integration_http_timeout_is_reasonable() {
1975        const _: () = {
1976            // Verify the timeout constant is short enough for startup
1977            assert!(
1978                HTTP_TIMEOUT_SECS <= 10,
1979                "HTTP timeout should be short to avoid blocking startup"
1980            );
1981            assert!(
1982                HTTP_TIMEOUT_SECS >= 3,
1983                "HTTP timeout should be long enough for slow networks"
1984            );
1985        };
1986    }
1987
1988    #[test]
1989    #[serial]
1990    fn integration_check_interval_is_reasonable() {
1991        const _: () = {
1992            // Verify check interval is reasonable (not too frequent, not too rare)
1993            assert!(
1994                CHECK_INTERVAL_SECS >= 3600,
1995                "should not check more than once per hour"
1996            );
1997            assert!(
1998                CHECK_INTERVAL_SECS <= 86400,
1999                "should check at least once per day"
2000            );
2001        };
2002    }
2003}