1use 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"));
35const AUTO_UPDATE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
37const AUTO_UPDATE_BOOT_DELAY: Duration = Duration::from_secs(90);
40
41const 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#[derive(Default)]
56pub struct UpdateOptions {
57 pub check_only: bool,
58 pub version: Option<String>,
59 pub force: bool,
60}
61
62struct ResolvedAsset {
64 version: String,
65 url: String,
66 sha256: Option<String>,
68}
69
70pub struct AppliedUpdate {
72 pub version: String,
73 pub binaries: Vec<String>,
74}
75
76fn 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
91fn 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
105pub 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
120async 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
181async 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
221fn 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
255fn 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
298fn 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
310async 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
352pub 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 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 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
436pub fn auto_update_enabled() -> bool {
440 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 match running_exe() {
458 Ok((exe, _)) => !running_in_app_bundle(&exe),
459 Err(_) => false,
460 }
461}
462
463fn 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
481pub 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); }
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 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
510pub 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 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 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 #[cfg(unix)]
599 #[test]
600 fn symlink_into_bundle_canonicalizes_and_is_detected() {
601 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 assert!(!running_in_app_bundle(&link));
620 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 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}