use std::path::{Path, PathBuf};
use std::time::Duration;
use sha2::{Digest, Sha256};
const RELEASES_REPO: &str = "Parslee-ai/car-releases";
const USER_AGENT: &str = concat!("car-self-update/", env!("CARGO_PKG_VERSION"));
const AUTO_UPDATE_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const AUTO_UPDATE_BOOT_DELAY: Duration = Duration::from_secs(90);
#[derive(Default)]
pub struct UpdateOptions {
pub check_only: bool,
pub version: Option<String>,
pub force: bool,
}
struct ResolvedAsset {
version: String,
url: String,
sha256: Option<String>,
}
pub struct AppliedUpdate {
pub version: String,
pub binaries: Vec<String>,
}
fn platform_asset() -> Result<&'static str, String> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("car-darwin-arm64.tar.gz"),
("linux", "x86_64") => Ok("car-linux-x64-gnu.tar.gz"),
("linux", "aarch64") => Ok("car-linux-arm64-gnu.tar.gz"),
("windows", "x86_64") => Ok("car-win32-x64-msvc.zip"),
(os, arch) => Err(format!(
"no CAR release archive for {os}/{arch} — install manually from \
https://github.com/{RELEASES_REPO}/releases"
)),
}
}
fn managed_binaries() -> &'static [&'static str] {
if cfg!(windows) {
&["car.exe", "car-server.exe", "car-memgine-eval.exe"]
} else {
&["car", "car-server", "car-memgine-eval"]
}
}
fn normalize_version(v: &str) -> String {
v.trim().trim_start_matches('v').to_string()
}
pub fn running_in_app_bundle(exe: &Path) -> bool {
exe.components()
.any(|c| c.as_os_str().to_str().is_some_and(|s| s.ends_with(".app")))
}
fn http_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(|e| format!("build HTTP client: {e}"))
}
async fn resolve_asset(
client: &reqwest::Client,
version: Option<&str>,
) -> Result<ResolvedAsset, String> {
let want_asset = platform_asset()?;
let api = match version {
Some(v) => format!(
"https://api.github.com/repos/{RELEASES_REPO}/releases/tags/v{}",
normalize_version(v)
),
None => format!("https://api.github.com/repos/{RELEASES_REPO}/releases/latest"),
};
let body: serde_json::Value = client
.get(&api)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.map_err(|e| format!("query release: {e}"))?
.error_for_status()
.map_err(|e| format!("query release: {e}"))?
.json()
.await
.map_err(|e| format!("parse release JSON: {e}"))?;
let version = body
.get("tag_name")
.and_then(|t| t.as_str())
.map(normalize_version)
.filter(|s| !s.is_empty())
.ok_or_else(|| "release had no tag_name".to_string())?;
let asset = body
.get("assets")
.and_then(|a| a.as_array())
.into_iter()
.flatten()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(want_asset))
.ok_or_else(|| format!("release {version} has no asset {want_asset}"))?;
let url = asset
.get("browser_download_url")
.and_then(|u| u.as_str())
.ok_or_else(|| "asset had no download URL".to_string())?
.to_string();
let sha256 = asset
.get("digest")
.and_then(|d| d.as_str())
.and_then(|d| d.strip_prefix("sha256:"))
.map(str::to_string);
Ok(ResolvedAsset {
version,
url,
sha256,
})
}
async fn download_and_verify(
client: &reqwest::Client,
asset: &ResolvedAsset,
dest: &Path,
) -> Result<(), String> {
let bytes = client
.get(&asset.url)
.send()
.await
.map_err(|e| format!("download {}: {e}", asset.url))?
.error_for_status()
.map_err(|e| format!("download {}: {e}", asset.url))?
.bytes()
.await
.map_err(|e| format!("read download body: {e}"))?;
if let Some(expected) = &asset.sha256 {
let got = hex_lower(&Sha256::digest(&bytes));
if !got.eq_ignore_ascii_case(expected) {
return Err(format!(
"integrity check failed for {}: expected sha256 {expected}, got {got} — \
refusing to install",
asset.url
));
}
}
std::fs::write(dest, &bytes).map_err(|e| format!("write {}: {e}", dest.display()))
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn extract(archive: &Path, dest: &Path) -> Result<(), String> {
let status = std::process::Command::new("tar")
.arg("-xf")
.arg(archive)
.arg("-C")
.arg(dest)
.status()
.map_err(|e| format!("run tar (is it on PATH?): {e}"))?;
if !status.success() {
return Err(format!("tar failed to extract {}", archive.display()));
}
Ok(())
}
fn find_in_tree(root: &Path, name: &str) -> Option<PathBuf> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.file_name().and_then(|n| n.to_str()) == Some(name) {
return Some(path);
}
}
}
None
}
fn replace_binary(src: &Path, dst: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(src, std::fs::Permissions::from_mode(0o755));
}
let dir = dst
.parent()
.ok_or_else(|| format!("{} has no parent directory", dst.display()))?;
let staged = dir.join(format!(
".{}.new",
dst.file_name().and_then(|n| n.to_str()).unwrap_or("car")
));
std::fs::copy(src, &staged).map_err(|e| format!("stage {}: {e}", staged.display()))?;
#[cfg(windows)]
if dst.exists() {
let old = dst.with_extension("old");
let _ = std::fs::remove_file(&old);
std::fs::rename(dst, &old)
.map_err(|e| format!("move aside {} (running elsewhere?): {e}", dst.display()))?;
}
std::fs::rename(&staged, dst).map_err(|e| {
let _ = std::fs::remove_file(&staged);
format!("install {}: {e}", dst.display())
})
}
fn dir_writable(dir: &Path) -> bool {
let probe = dir.join(".car-update-write-probe");
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
fn running_exe() -> Result<(PathBuf, PathBuf), String> {
let exe = std::env::current_exe().map_err(|e| format!("locate running binary: {e}"))?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let dir = exe
.parent()
.ok_or_else(|| format!("{} has no parent directory", exe.display()))?
.to_path_buf();
Ok((exe, dir))
}
async fn apply(
client: &reqwest::Client,
asset: &ResolvedAsset,
install_dir: &Path,
running: &Path,
) -> Result<AppliedUpdate, String> {
let tmp = std::env::temp_dir().join(format!("car-update-{}", asset.version));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| format!("create temp dir: {e}"))?;
let _guard = TempCleanup(tmp.clone());
let archive = tmp.join(platform_asset()?);
download_and_verify(client, asset, &archive).await?;
let unpack = tmp.join("unpacked");
std::fs::create_dir_all(&unpack).map_err(|e| format!("create unpack dir: {e}"))?;
extract(&archive, &unpack)?;
let mut updated = Vec::new();
for name in managed_binaries() {
let dst = install_dir.join(name);
let is_running = dst == running;
if !is_running && !dst.exists() {
continue;
}
let Some(src) = find_in_tree(&unpack, name) else {
if is_running {
return Err(format!("archive did not contain {name}"));
}
continue;
};
replace_binary(&src, &dst)?;
updated.push(name.to_string());
}
Ok(AppliedUpdate {
version: asset.version.clone(),
binaries: updated,
})
}
pub async fn run_update(opts: UpdateOptions, emit: &dyn Fn(&str)) -> Result<(), String> {
let current = env!("CARGO_PKG_VERSION").to_string();
let (exe, install_dir) = running_exe()?;
let client = http_client()?;
let asset = resolve_asset(&client, opts.version.as_deref()).await?;
emit(&format!(
"car {current} (installed at {})",
install_dir.display()
));
emit(&format!("latest: {}", asset.version));
if asset.version == current && !opts.force {
emit("✓ already up to date.");
return Ok(());
}
if opts.check_only {
if asset.version == current {
emit("✓ on the latest version.");
} else {
emit(&format!(
"→ update available: {current} → {}. Run `car update` to install.",
asset.version
));
}
return Ok(());
}
if !dir_writable(&install_dir) {
return Err(format!(
"{} is not writable — re-run with elevated privileges:\n sudo car update",
install_dir.display()
));
}
emit(&format!("↓ downloading {} …", platform_asset()?));
let applied = apply(&client, &asset, &install_dir, &exe).await?;
emit(&format!(
"✓ updated to {}: {}",
applied.version,
applied.binaries.join(", ")
));
emit(
" Restart the daemon to load the new server: `car daemon restart` (or relaunch CarHost).",
);
if cfg!(target_os = "macos") {
emit(" CarHost.app updates separately via its built-in updater (Check for Updates…).");
}
Ok(())
}
pub fn auto_update_enabled() -> bool {
match std::env::var("CAR_AUTO_UPDATE") {
Ok(v) => {
if matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
) {
return false;
}
}
Err(_) => {
if config_auto_update_disabled() {
return false;
}
}
}
match running_exe() {
Ok((exe, _)) => !running_in_app_bundle(&exe),
Err(_) => false,
}
}
fn config_auto_update_disabled() -> bool {
let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
.map(std::path::PathBuf::from)
.or_else(|| std::env::current_dir().ok())
else {
return false;
};
let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
return false;
};
matches!(
car_memgine::project::load_config_overrides(&car_dir).and_then(|c| c.auto_update),
Some(false)
)
}
pub async fn auto_update_tick() -> Result<Option<String>, String> {
let current = env!("CARGO_PKG_VERSION");
let (exe, install_dir) = running_exe()?;
if running_in_app_bundle(&exe) {
return Ok(None); }
if !dir_writable(&install_dir) {
return Err(format!(
"auto-update: {} not writable (needs elevated install); skipping",
install_dir.display()
));
}
let client = http_client()?;
let asset = resolve_asset(&client, None).await?;
let force = std::env::var_os("CAR_AUTO_UPDATE_FORCE").is_some();
if asset.version == current && !force {
return Ok(None);
}
let applied = apply(&client, &asset, &install_dir, &exe).await?;
Ok(Some(applied.version))
}
pub fn spawn_auto_update() {
if !auto_update_enabled() {
tracing::info!(
target: "car::auto_update",
"auto-update disabled (CAR_AUTO_UPDATE=0 or running inside an app bundle)"
);
return;
}
let boot_delay = std::env::var("CAR_AUTO_UPDATE_BOOT_DELAY_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(AUTO_UPDATE_BOOT_DELAY);
tokio::spawn(async move {
tokio::time::sleep(boot_delay).await;
let mut tick = tokio::time::interval(AUTO_UPDATE_INTERVAL);
loop {
tick.tick().await;
match auto_update_tick().await {
Ok(Some(v)) => tracing::info!(
target: "car::auto_update",
version = %v,
"auto-update installed {v}; takes effect on next daemon/CLI launch"
),
Ok(None) => tracing::debug!(target: "car::auto_update", "already up to date"),
Err(e) => {
tracing::warn!(target: "car::auto_update", error = %e, "auto-update check failed")
}
}
}
});
}
struct TempCleanup(PathBuf);
impl Drop for TempCleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_v_prefix_and_whitespace() {
assert_eq!(normalize_version(" v0.34.0\n"), "0.34.0");
assert_eq!(normalize_version("0.34.0"), "0.34.0");
}
#[test]
fn platform_asset_resolves_or_errors_cleanly() {
match platform_asset() {
Ok(a) => {
assert!(a.starts_with("car-") && (a.ends_with(".tar.gz") || a.ends_with(".zip")))
}
Err(e) => assert!(e.contains("no CAR release archive")),
}
}
#[test]
fn hex_lower_matches_known_sha256() {
let empty = Sha256::digest(b"");
assert!(hex_lower(&empty).starts_with("e3b0c44298fc1c14"));
}
#[test]
fn app_bundle_detection() {
assert!(running_in_app_bundle(Path::new(
"/Applications/CarHost.app/Contents/MacOS/car-server"
)));
assert!(!running_in_app_bundle(Path::new("/usr/local/bin/car")));
assert!(!running_in_app_bundle(Path::new(
"/Users/x/.car/bin/car-server"
)));
}
#[test]
fn auto_update_disabled_by_env() {
let prev = std::env::var_os("CAR_AUTO_UPDATE");
unsafe { std::env::set_var("CAR_AUTO_UPDATE", "0") };
assert!(!auto_update_enabled());
unsafe {
match prev {
Some(v) => std::env::set_var("CAR_AUTO_UPDATE", v),
None => std::env::remove_var("CAR_AUTO_UPDATE"),
}
}
}
#[test]
fn find_in_tree_locates_nested_binary() {
let tmp = std::env::temp_dir().join(format!("car-sscore-ut-{}", std::process::id()));
let nested = tmp.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("car-server"), b"x").unwrap();
assert_eq!(
find_in_tree(&tmp, "car-server"),
Some(nested.join("car-server"))
);
assert_eq!(find_in_tree(&tmp, "nope"), None);
let _ = std::fs::remove_dir_all(&tmp);
}
}