Skip to main content

car_server_core/
self_update.rs

1//! Self-update core + the default-on auto-update daemon task.
2//!
3//! Version skew across CAR's independent install/update channels is the problem
4//! this closes: the CLI, the `car-server` daemon, and `CarHost.app` update
5//! through different mechanisms, so components silently drift apart (a stale
6//! `/usr/local/bin/car` that Sparkle's app-update never touched sends a login
7//! redirect a newer server rejects). This module owns the mechanism both the
8//! `car update` CLI command and the daemon's background auto-updater share:
9//! resolve the latest release, download the platform archive, verify it against
10//! the GitHub-reported SHA-256, and atomically replace the running `car` CLI +
11//! its sibling `car-server` in place.
12//!
13//! **Auto-update is on by default.** The daemon checks on boot and daily and
14//! applies updates itself. It is careful:
15//! - it **defers to Sparkle** when running inside `CarHost.app` (the app bundle
16//!   owns its embedded daemon), never fighting the app updater;
17//! - it **verifies the SHA-256** the GitHub API reports for the asset before
18//!   replacing anything, so a corrupted or tampered download is rejected;
19//! - it stages the new binaries in place and lets them take effect on the next
20//!   launch, so a running session is never interrupted mid-flight;
21//! - it is disabled by `CAR_AUTO_UPDATE=0` or `.car/config.toml`
22//!   `auto_update = false`.
23//!
24//! The npm/PyPI `car-runtime` packages are project dependencies (pinned in a
25//! lockfile) and are never reached into — they update with `npm update` / `pip
26//! install -U`.
27
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use sha2::{Digest, Sha256};
32
33const RELEASES_REPO: &str = "Parslee-ai/car-releases";
34const USER_AGENT: &str = concat!("car-self-update/", env!("CARGO_PKG_VERSION"));
35/// Daily cadence for the background checker.
36const AUTO_UPDATE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
37/// Grace period after boot before the first check, so startup isn't slowed and a
38/// crash-looping daemon doesn't hammer the API.
39const AUTO_UPDATE_BOOT_DELAY: Duration = Duration::from_secs(90);
40
41/// Printed at both exits of [`run_update`] so the scope of what it just did (or
42/// declined to do) is bounded at the point the claim is made.
43///
44/// `car update` and CarHost's Sparkle updater both move binaries: the `car` CLI
45/// and `car-server`. Neither can see a `car-runtime` wheel or npm package inside
46/// a consumer's own environment, which is the usual stale component when the
47/// handshake reports skew. Saying so here is half of car#1050; the other half is
48/// the handshake notice naming the executable that actually loaded the client
49/// (`car_daemon_client::proxy`).
50const CLIENT_SCOPE_CAVEAT: &str = "  Scope: this covers the `car` CLI + `car-server` only — \
51     npm/PyPI `car-runtime` client libraries live in their own environments and update there \
52     (`car doctor` lists the ones it can see).";
53
54/// Options for a one-shot `car update` invocation.
55#[derive(Default)]
56pub struct UpdateOptions {
57    pub check_only: bool,
58    pub version: Option<String>,
59    pub force: bool,
60}
61
62/// A resolved release: which version, and the platform asset's URL + digest.
63struct ResolvedAsset {
64    version: String,
65    url: String,
66    /// SHA-256 hex (without the `sha256:` prefix), if the API reported one.
67    sha256: Option<String>,
68}
69
70/// What an applied update changed.
71pub struct AppliedUpdate {
72    pub version: String,
73    pub binaries: Vec<String>,
74}
75
76/// The release archive asset for the host platform. Mirrors the asset names
77/// `scripts/release.sh` produces and `install.sh`/`install.js` consume.
78fn platform_asset() -> Result<&'static str, String> {
79    match (std::env::consts::OS, std::env::consts::ARCH) {
80        ("macos", "aarch64") => Ok("car-darwin-arm64.tar.gz"),
81        ("linux", "x86_64") => Ok("car-linux-x64-gnu.tar.gz"),
82        ("linux", "aarch64") => Ok("car-linux-arm64-gnu.tar.gz"),
83        ("windows", "x86_64") => Ok("car-win32-x64-msvc.zip"),
84        (os, arch) => Err(format!(
85            "no CAR release archive for {os}/{arch} — install manually from \
86             https://github.com/{RELEASES_REPO}/releases"
87        )),
88    }
89}
90
91/// Binaries reconciled when they sit next to the running CLI/daemon. The running
92/// binary is always replaced; the others only if already installed alongside it.
93fn managed_binaries() -> &'static [&'static str] {
94    if cfg!(windows) {
95        &["car.exe", "car-server.exe", "car-memgine-eval.exe"]
96    } else {
97        &["car", "car-server", "car-memgine-eval"]
98    }
99}
100
101fn normalize_version(v: &str) -> String {
102    v.trim().trim_start_matches('v').to_string()
103}
104
105/// True when the running executable lives inside a macOS `.app` bundle — those
106/// are Sparkle's territory (the app ships and updates its own embedded daemon),
107/// so the binary auto-updater stands down to avoid fighting the app updater.
108pub fn running_in_app_bundle(exe: &Path) -> bool {
109    exe.components()
110        .any(|c| c.as_os_str().to_str().is_some_and(|s| s.ends_with(".app")))
111}
112
113fn http_client() -> Result<reqwest::Client, String> {
114    reqwest::Client::builder()
115        .user_agent(USER_AGENT)
116        .build()
117        .map_err(|e| format!("build HTTP client: {e}"))
118}
119
120/// Resolve the platform asset for `version` (or the latest release) from the
121/// GitHub API, returning its download URL and reported SHA-256. Using the API
122/// (rather than a templated URL) gets us the digest for free — the integrity
123/// check that makes silent auto-update safe.
124async fn resolve_asset(
125    client: &reqwest::Client,
126    version: Option<&str>,
127) -> Result<ResolvedAsset, String> {
128    let want_asset = platform_asset()?;
129    let api = match version {
130        Some(v) => format!(
131            "https://api.github.com/repos/{RELEASES_REPO}/releases/tags/v{}",
132            normalize_version(v)
133        ),
134        None => format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest"),
135    };
136    let body: serde_json::Value = client
137        .get(&api)
138        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
139        .send()
140        .await
141        .map_err(|e| format!("query release: {e}"))?
142        .error_for_status()
143        .map_err(|e| format!("query release: {e}"))?
144        .json()
145        .await
146        .map_err(|e| format!("parse release JSON: {e}"))?;
147
148    let version = body
149        .get("tag_name")
150        .and_then(|t| t.as_str())
151        .map(normalize_version)
152        .filter(|s| !s.is_empty())
153        .ok_or_else(|| "release had no tag_name".to_string())?;
154
155    let asset = body
156        .get("assets")
157        .and_then(|a| a.as_array())
158        .into_iter()
159        .flatten()
160        .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(want_asset))
161        .ok_or_else(|| format!("release {version} has no asset {want_asset}"))?;
162
163    let url = asset
164        .get("browser_download_url")
165        .and_then(|u| u.as_str())
166        .ok_or_else(|| "asset had no download URL".to_string())?
167        .to_string();
168    let sha256 = asset
169        .get("digest")
170        .and_then(|d| d.as_str())
171        .and_then(|d| d.strip_prefix("sha256:"))
172        .map(str::to_string);
173
174    Ok(ResolvedAsset {
175        version,
176        url,
177        sha256,
178    })
179}
180
181/// Download `url` to `dest`, verifying the SHA-256 when the API reported one.
182/// A digest mismatch is a hard error — we never replace binaries with bytes we
183/// couldn't authenticate against the API's record.
184async fn download_and_verify(
185    client: &reqwest::Client,
186    asset: &ResolvedAsset,
187    dest: &Path,
188) -> Result<(), String> {
189    let bytes = client
190        .get(&asset.url)
191        .send()
192        .await
193        .map_err(|e| format!("download {}: {e}", asset.url))?
194        .error_for_status()
195        .map_err(|e| format!("download {}: {e}", asset.url))?
196        .bytes()
197        .await
198        .map_err(|e| format!("read download body: {e}"))?;
199
200    if let Some(expected) = &asset.sha256 {
201        let got = hex_lower(&Sha256::digest(&bytes));
202        if !got.eq_ignore_ascii_case(expected) {
203            return Err(format!(
204                "integrity check failed for {}: expected sha256 {expected}, got {got} — \
205                 refusing to install",
206                asset.url
207            ));
208        }
209    }
210    std::fs::write(dest, &bytes).map_err(|e| format!("write {}: {e}", dest.display()))
211}
212
213fn hex_lower(bytes: &[u8]) -> String {
214    let mut s = String::with_capacity(bytes.len() * 2);
215    for b in bytes {
216        s.push_str(&format!("{b:02x}"));
217    }
218    s
219}
220
221/// Extract `archive` (`.tar.gz` or `.zip`) into `dest` via the system `tar`
222/// (present on macOS, Linux, and Windows 10+; bsdtar handles zip too).
223fn extract(archive: &Path, dest: &Path) -> Result<(), String> {
224    let status = std::process::Command::new("tar")
225        .arg("-xf")
226        .arg(archive)
227        .arg("-C")
228        .arg(dest)
229        .status()
230        .map_err(|e| format!("run tar (is it on PATH?): {e}"))?;
231    if !status.success() {
232        return Err(format!("tar failed to extract {}", archive.display()));
233    }
234    Ok(())
235}
236
237fn find_in_tree(root: &Path, name: &str) -> Option<PathBuf> {
238    let mut stack = vec![root.to_path_buf()];
239    while let Some(dir) = stack.pop() {
240        let Ok(entries) = std::fs::read_dir(&dir) else {
241            continue;
242        };
243        for entry in entries.flatten() {
244            let path = entry.path();
245            if path.is_dir() {
246                stack.push(path);
247            } else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
248                return Some(path);
249            }
250        }
251    }
252    None
253}
254
255/// Replace `dst` with `src` in place. On Unix a same-directory rename is atomic
256/// and safe while the old binary runs (the inode outlives the process). On
257/// Windows a running `.exe` can't be overwritten but can be renamed aside.
258fn replace_binary(src: &Path, dst: &Path) -> Result<(), String> {
259    #[cfg(unix)]
260    {
261        use std::os::unix::fs::PermissionsExt;
262        let _ = std::fs::set_permissions(src, std::fs::Permissions::from_mode(0o755));
263    }
264    let dir = dst
265        .parent()
266        .ok_or_else(|| format!("{} has no parent directory", dst.display()))?;
267    let staged = dir.join(format!(
268        ".{}.new",
269        dst.file_name().and_then(|n| n.to_str()).unwrap_or("car")
270    ));
271    std::fs::copy(src, &staged).map_err(|e| format!("stage {}: {e}", staged.display()))?;
272
273    #[cfg(windows)]
274    if dst.exists() {
275        let old = dst.with_extension("old");
276        let _ = std::fs::remove_file(&old);
277        std::fs::rename(dst, &old)
278            .map_err(|e| format!("move aside {} (running elsewhere?): {e}", dst.display()))?;
279    }
280
281    std::fs::rename(&staged, dst).map_err(|e| {
282        let _ = std::fs::remove_file(&staged);
283        format!("install {}: {e}", dst.display())
284    })
285}
286
287fn dir_writable(dir: &Path) -> bool {
288    let probe = dir.join(".car-update-write-probe");
289    match std::fs::File::create(&probe) {
290        Ok(_) => {
291            let _ = std::fs::remove_file(&probe);
292            true
293        }
294        Err(_) => false,
295    }
296}
297
298/// Resolve the running executable, following symlinks so we replace the real
299/// file. Returns `(canonical_exe, install_dir)`.
300fn running_exe() -> Result<(PathBuf, PathBuf), String> {
301    let exe = std::env::current_exe().map_err(|e| format!("locate running binary: {e}"))?;
302    let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
303    let dir = exe
304        .parent()
305        .ok_or_else(|| format!("{} has no parent directory", exe.display()))?
306        .to_path_buf();
307    Ok((exe, dir))
308}
309
310/// Download, verify, extract, and replace the managed binaries in `install_dir`
311/// for `asset`. Only touches binaries already present alongside `running`.
312async fn apply(
313    client: &reqwest::Client,
314    asset: &ResolvedAsset,
315    install_dir: &Path,
316    running: &Path,
317) -> Result<AppliedUpdate, String> {
318    let tmp = std::env::temp_dir().join(format!("car-update-{}", asset.version));
319    let _ = std::fs::remove_dir_all(&tmp);
320    std::fs::create_dir_all(&tmp).map_err(|e| format!("create temp dir: {e}"))?;
321    let _guard = TempCleanup(tmp.clone());
322
323    let archive = tmp.join(platform_asset()?);
324    download_and_verify(client, asset, &archive).await?;
325
326    let unpack = tmp.join("unpacked");
327    std::fs::create_dir_all(&unpack).map_err(|e| format!("create unpack dir: {e}"))?;
328    extract(&archive, &unpack)?;
329
330    let mut updated = Vec::new();
331    for name in managed_binaries() {
332        let dst = install_dir.join(name);
333        let is_running = dst == running;
334        if !is_running && !dst.exists() {
335            continue;
336        }
337        let Some(src) = find_in_tree(&unpack, name) else {
338            if is_running {
339                return Err(format!("archive did not contain {name}"));
340            }
341            continue;
342        };
343        replace_binary(&src, &dst)?;
344        updated.push(name.to_string());
345    }
346    Ok(AppliedUpdate {
347        version: asset.version.clone(),
348        binaries: updated,
349    })
350}
351
352/// Drive a one-shot `car update`. `emit` receives human-readable progress lines
353/// (the CLI prints them; other callers can log them).
354pub async fn run_update(opts: UpdateOptions, emit: &dyn Fn(&str)) -> Result<(), String> {
355    let current = env!("CARGO_PKG_VERSION").to_string();
356    let (exe, install_dir) = running_exe()?;
357
358    // The CLI now ships INSIDE CarHost.app (Contents/MacOS/car), and
359    // /usr/local/bin/car is a symlink into the bundle. running_exe()
360    // canonicalizes through that symlink, so an in-place `car update`
361    // here would overwrite the Sparkle-managed bundle binaries (car +
362    // its sibling car-server, both under Contents/MacOS/) and break the
363    // app's code signature. Sparkle owns everything inside the bundle;
364    // defer to it — same stance the background auto-updater already
365    // takes (see running_in_app_bundle / auto_update_enabled). This
366    // deferral is unconditional (even with --force/--check): there is no
367    // safe in-place replace of a signed bundle member.
368    if running_in_app_bundle(&exe) {
369        emit(&format!(
370            "car {current} is part of CarHost.app ({}).",
371            install_dir.display()
372        ));
373        emit("  Updates are delivered by CarHost's built-in updater, not `car update`.");
374        emit("  Use CarHost ▸ Check for Updates… (or relaunch CarHost to apply a staged update).");
375        emit(CLIENT_SCOPE_CAVEAT);
376        return Ok(());
377    }
378
379    let client = http_client()?;
380    let asset = resolve_asset(&client, opts.version.as_deref()).await?;
381
382    emit(&format!(
383        "car {current}  (installed at {})",
384        install_dir.display()
385    ));
386    emit(&format!("latest: {}", asset.version));
387
388    if asset.version == current && !opts.force {
389        emit("✓ already up to date.");
390        return Ok(());
391    }
392    if opts.check_only {
393        if asset.version == current {
394            emit("✓ on the latest version.");
395        } else {
396            emit(&format!(
397                "→ update available: {current} → {}. Run `car update` to install.",
398                asset.version
399            ));
400        }
401        return Ok(());
402    }
403    if !dir_writable(&install_dir) {
404        // Name the elevation the caller's OS actually has. `sudo` does not exist on
405        // Windows, and this is not an edge case there: the Inno installer puts CAR
406        // in `C:\Program Files\CAR`, which is never user-writable, so the
407        // officially-installed Windows user is precisely who reaches this branch.
408        let elevate = if cfg!(windows) {
409            "re-run from an elevated terminal (right-click Windows Terminal or Command Prompt, then Run as administrator):\n    car update"
410        } else {
411            "re-run with elevated privileges:\n    sudo car update"
412        };
413        return Err(format!(
414            "{} is not writable — {elevate}",
415            install_dir.display()
416        ));
417    }
418
419    emit(&format!("↓ downloading {} …", platform_asset()?));
420    let applied = apply(&client, &asset, &install_dir, &exe).await?;
421    emit(&format!(
422        "✓ updated to {}: {}",
423        applied.version,
424        applied.binaries.join(", ")
425    ));
426    emit(
427        "  Restart the daemon to load the new server: `car daemon restart` (or relaunch CarHost).",
428    );
429    if cfg!(target_os = "macos") {
430        emit("  CarHost.app updates separately via its built-in updater (Check for Updates…).");
431    }
432    emit(CLIENT_SCOPE_CAVEAT);
433    Ok(())
434}
435
436/// Whether the background auto-updater should run, honoring the opt-outs.
437/// Default is ON. Off when `CAR_AUTO_UPDATE` is `0`/`false`/`off`, or when the
438/// running binary is inside a `.app` bundle (Sparkle owns that daemon).
439pub fn auto_update_enabled() -> bool {
440    // Explicit env override wins over the config file.
441    match std::env::var("CAR_AUTO_UPDATE") {
442        Ok(v) => {
443            if matches!(
444                v.trim().to_ascii_lowercase().as_str(),
445                "0" | "false" | "off" | "no"
446            ) {
447                return false;
448            }
449        }
450        Err(_) => {
451            if config_auto_update_disabled() {
452                return false;
453            }
454        }
455    }
456    // Always defer to Sparkle for the app-bundled daemon, whatever the config.
457    match running_exe() {
458        Ok((exe, _)) => !running_in_app_bundle(&exe),
459        Err(_) => false,
460    }
461}
462
463/// `.car/config.toml` `auto_update = false` (discovered from `CAR_PROJECT_DIR`
464/// or cwd, like the evolution cadence). Absent config or key = not disabled.
465fn config_auto_update_disabled() -> bool {
466    let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
467        .map(std::path::PathBuf::from)
468        .or_else(|| std::env::current_dir().ok())
469    else {
470        return false;
471    };
472    let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
473        return false;
474    };
475    matches!(
476        car_memgine::project::load_config_overrides(&car_dir).and_then(|c| c.auto_update),
477        Some(false)
478    )
479}
480
481/// One auto-update check: if a newer version exists, download+verify+replace the
482/// on-disk binaries. Returns `Some(version)` when an update was applied (it
483/// takes effect on the next launch), `None` when already current. Errors are
484/// returned for the caller to log; auto-update never panics a running daemon.
485pub async fn auto_update_tick() -> Result<Option<String>, String> {
486    let current = env!("CARGO_PKG_VERSION");
487    let (exe, install_dir) = running_exe()?;
488    if running_in_app_bundle(&exe) {
489        return Ok(None); // Sparkle's job.
490    }
491    if !dir_writable(&install_dir) {
492        return Err(format!(
493            "auto-update: {} not writable (needs elevated install); skipping",
494            install_dir.display()
495        ));
496    }
497    let client = http_client()?;
498    let asset = resolve_asset(&client, None).await?;
499    // `CAR_AUTO_UPDATE_FORCE=1` reinstalls the latest even when it matches the
500    // running version — a recovery/verification knob (repair a corrupt install,
501    // or exercise the full download→verify→replace path when already current).
502    let force = std::env::var_os("CAR_AUTO_UPDATE_FORCE").is_some();
503    if asset.version == current && !force {
504        return Ok(None);
505    }
506    let applied = apply(&client, &asset, &install_dir, &exe).await?;
507    Ok(Some(applied.version))
508}
509
510/// Spawn the default-on background auto-updater: after a short boot grace period,
511/// check on a daily cadence and self-update in place. Non-fatal — any error is
512/// logged and the loop continues. No-op (returns without spawning) when disabled.
513pub fn spawn_auto_update() {
514    if !auto_update_enabled() {
515        tracing::info!(
516            target: "car::auto_update",
517            "auto-update disabled (CAR_AUTO_UPDATE=0 or running inside an app bundle)"
518        );
519        return;
520    }
521    // Boot delay is overridable via CAR_AUTO_UPDATE_BOOT_DELAY_SECS (tuning /
522    // fast tests); defaults to the 90s grace period.
523    let boot_delay = std::env::var("CAR_AUTO_UPDATE_BOOT_DELAY_SECS")
524        .ok()
525        .and_then(|s| s.parse::<u64>().ok())
526        .map(Duration::from_secs)
527        .unwrap_or(AUTO_UPDATE_BOOT_DELAY);
528    tokio::spawn(async move {
529        tokio::time::sleep(boot_delay).await;
530        let mut tick = tokio::time::interval(AUTO_UPDATE_INTERVAL);
531        loop {
532            tick.tick().await;
533            match auto_update_tick().await {
534                Ok(Some(v)) => tracing::info!(
535                    target: "car::auto_update",
536                    version = %v,
537                    "auto-update installed {v}; takes effect on next daemon/CLI launch"
538                ),
539                Ok(None) => tracing::debug!(target: "car::auto_update", "already up to date"),
540                Err(e) => {
541                    tracing::warn!(target: "car::auto_update", error = %e, "auto-update check failed")
542                }
543            }
544        }
545    });
546}
547
548struct TempCleanup(PathBuf);
549impl Drop for TempCleanup {
550    fn drop(&mut self) {
551        let _ = std::fs::remove_dir_all(&self.0);
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn normalize_strips_v_prefix_and_whitespace() {
561        assert_eq!(normalize_version(" v0.34.0\n"), "0.34.0");
562        assert_eq!(normalize_version("0.34.0"), "0.34.0");
563    }
564
565    #[test]
566    fn platform_asset_resolves_or_errors_cleanly() {
567        match platform_asset() {
568            Ok(a) => {
569                assert!(a.starts_with("car-") && (a.ends_with(".tar.gz") || a.ends_with(".zip")))
570            }
571            Err(e) => assert!(e.contains("no CAR release archive")),
572        }
573    }
574
575    #[test]
576    fn hex_lower_matches_known_sha256() {
577        // sha256("") = e3b0c442...
578        let empty = Sha256::digest(b"");
579        assert!(hex_lower(&empty).starts_with("e3b0c44298fc1c14"));
580    }
581
582    #[test]
583    fn app_bundle_detection() {
584        assert!(running_in_app_bundle(Path::new(
585            "/Applications/CarHost.app/Contents/MacOS/car-server"
586        )));
587        assert!(!running_in_app_bundle(Path::new("/usr/local/bin/car")));
588        assert!(!running_in_app_bundle(Path::new(
589            "/Users/x/.car/bin/car-server"
590        )));
591    }
592
593    // Unix-only: the /usr/local/bin/car → CarHost.app symlink scheme is the
594    // macOS install layout, and creating symlinks on Windows CI needs
595    // privileges. `running_in_app_bundle` itself is covered cross-platform by
596    // `app_bundle_detection`; this test specifically exercises the symlink
597    // canonicalization that only exists on unix.
598    #[cfg(unix)]
599    #[test]
600    fn symlink_into_bundle_canonicalizes_and_is_detected() {
601        // Option A: /usr/local/bin/car is a symlink INTO CarHost.app.
602        // run_update() calls running_exe(), which canonicalizes through
603        // the symlink — so the bundle check must fire on the resolved
604        // path, not the symlink path (which alone looks external). This
605        // is what makes `car update` defer to Sparkle instead of
606        // clobbering the signed bundle.
607        let tmp = std::env::temp_dir().join(format!("car-symlink-ut-{}", std::process::id()));
608        let _ = std::fs::remove_dir_all(&tmp);
609        let macos = tmp.join("CarHost.app").join("Contents").join("MacOS");
610        std::fs::create_dir_all(&macos).unwrap();
611        let real = macos.join("car");
612        std::fs::write(&real, b"#!/bin/true\n").unwrap();
613        let bin_dir = tmp.join("usr").join("local").join("bin");
614        std::fs::create_dir_all(&bin_dir).unwrap();
615        let link = bin_dir.join("car");
616        std::os::unix::fs::symlink(&real, &link).unwrap();
617
618        // The symlink path on its own is NOT inside a .app…
619        assert!(!running_in_app_bundle(&link));
620        // …but canonicalizing it (what running_exe does) resolves into
621        // the bundle, which IS Sparkle's territory.
622        let resolved = std::fs::canonicalize(&link).unwrap();
623        assert!(running_in_app_bundle(&resolved));
624
625        let _ = std::fs::remove_dir_all(&tmp);
626    }
627
628    #[test]
629    fn auto_update_disabled_by_env() {
630        // Guarded save/restore so we don't leak env across tests.
631        let prev = std::env::var_os("CAR_AUTO_UPDATE");
632        unsafe { std::env::set_var("CAR_AUTO_UPDATE", "0") };
633        assert!(!auto_update_enabled());
634        unsafe {
635            match prev {
636                Some(v) => std::env::set_var("CAR_AUTO_UPDATE", v),
637                None => std::env::remove_var("CAR_AUTO_UPDATE"),
638            }
639        }
640    }
641
642    #[test]
643    fn find_in_tree_locates_nested_binary() {
644        let tmp = std::env::temp_dir().join(format!("car-sscore-ut-{}", std::process::id()));
645        let nested = tmp.join("a").join("b");
646        std::fs::create_dir_all(&nested).unwrap();
647        std::fs::write(nested.join("car-server"), b"x").unwrap();
648        assert_eq!(
649            find_in_tree(&tmp, "car-server"),
650            Some(nested.join("car-server"))
651        );
652        assert_eq!(find_in_tree(&tmp, "nope"), None);
653        let _ = std::fs::remove_dir_all(&tmp);
654    }
655}