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