use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::RecallError;
const REPO: &str = "dnacenta/recall-echo";
pub const EXIT_UPDATE_AVAILABLE: i32 = 10;
pub struct UpdateOpts {
pub check: bool,
pub version: Option<String>,
pub force: bool,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
pub async fn run(opts: UpdateOpts) -> Result<i32, RecallError> {
let current = env!("CARGO_PKG_VERSION");
if let Some(tag) = opts.version.as_deref() {
validate_tag(tag)?;
}
let client = http_client()?;
let release = fetch_release(&client, opts.version.as_deref()).await?;
let target = normalize(&release.tag_name);
let ordering = compare(current, &target);
if opts.check {
return Ok(match ordering {
Some(std::cmp::Ordering::Equal) => {
println!("recall-echo {current} is up to date");
0
}
Some(std::cmp::Ordering::Less) => {
println!(
"recall-echo {current} installed; {} available",
release.tag_name
);
EXIT_UPDATE_AVAILABLE
}
Some(std::cmp::Ordering::Greater) => {
println!(
"recall-echo {current} is ahead of the {} release",
release.tag_name
);
0
}
None => {
println!(
"recall-echo {current} installed; release {} has an unrecognized version format",
release.tag_name
);
EXIT_UPDATE_AVAILABLE
}
});
}
match ordering {
Some(std::cmp::Ordering::Equal) if !opts.force => {
println!(
"recall-echo {current} is already the {} release",
release.tag_name
);
return Ok(0);
}
Some(std::cmp::Ordering::Equal) | Some(std::cmp::Ordering::Less) => {}
Some(std::cmp::Ordering::Greater) | None => {
if !(opts.version.is_some() && opts.force) {
return Err(RecallError::Other(format!(
"refusing to replace {current} with {} (downgrade or unrecognized version) — \
pass both --version {} and --force to do it anyway",
release.tag_name, release.tag_name
)));
}
}
}
let asset_name = asset_name(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| {
RecallError::Other(format!(
"no release binary for {}/{} — build from source with `cargo install recall-echo --locked`",
std::env::consts::OS,
std::env::consts::ARCH
))
})?;
let asset = release
.assets
.iter()
.find(|a| a.name == asset_name)
.ok_or_else(|| {
RecallError::Other(format!(
"release {} has no asset named {asset_name}",
release.tag_name
))
})?;
let install_path = resolve_install_path()?;
let install_dir = install_path.parent().ok_or_else(|| {
RecallError::Other(format!(
"{} has no parent directory",
install_path.display()
))
})?;
preflight_writable(install_dir)?;
println!(
"updating {} {current} \u{2192} {}{}",
install_path.display(),
release.tag_name,
relation_note(current, &target)
);
install(
&client,
&asset.browser_download_url,
&install_path,
current,
&target,
)
.await?;
println!("updated to {}", release.tag_name);
println!("note: long-running recall-echo processes (serve daemon, MCP) keep the old version until restarted");
Ok(0)
}
fn http_client() -> Result<reqwest::Client, RecallError> {
reqwest::Client::builder()
.user_agent(concat!("recall-echo/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(std::time::Duration::from_secs(30))
.https_only(true)
.redirect(reqwest::redirect::Policy::limited(5))
.build()
.map_err(|e| RecallError::Other(format!("http client: {e}")))
}
async fn fetch_release(
client: &reqwest::Client,
tag: Option<&str>,
) -> Result<Release, RecallError> {
let url = match tag {
Some(tag) => format!("https://api.github.com/repos/{REPO}/releases/tags/{tag}"),
None => format!("https://api.github.com/repos/{REPO}/releases/latest"),
};
let mut req = client.get(&url);
let token = ["GITHUB_TOKEN", "GH_TOKEN"]
.iter()
.find_map(|key| std::env::var(key).ok().filter(|t| !t.is_empty()));
if let Some(token) = token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| RecallError::Other(format!("github api: {e}")))?;
let status = resp.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Err(RecallError::Other(match tag {
Some(tag) => format!("release not found: no release tagged {tag}"),
None => "release not found: the repository has no releases".into(),
}));
}
if !status.is_success() {
return Err(RecallError::Other(format!(
"github api returned {status} for {url} (rate-limited? set GITHUB_TOKEN)"
)));
}
let release = resp
.json::<Release>()
.await
.map_err(|e| RecallError::Other(format!("github api response: {e}")))?;
if let Some(tag) = tag {
if release.tag_name != tag {
return Err(RecallError::Other(format!(
"github api returned release {} for requested tag {tag}",
release.tag_name
)));
}
}
Ok(release)
}
fn validate_asset_url(url: &str) -> Result<(), RecallError> {
let parsed = reqwest::Url::parse(url)
.map_err(|e| RecallError::Other(format!("bad asset url {url:?}: {e}")))?;
let host_ok = matches!(
parsed.host_str(),
Some(host) if host == "github.com" || host == "api.github.com"
|| host.ends_with(".githubusercontent.com")
);
if parsed.scheme() == "https" && host_ok {
Ok(())
} else {
Err(RecallError::Other(format!(
"refusing asset url {url} — not an https GitHub release host"
)))
}
}
fn asset_name(os: &str, arch: &str) -> Option<String> {
let target = match (os, arch) {
("linux", "x86_64") => "x86_64-unknown-linux-gnu",
("linux", "aarch64") => "aarch64-unknown-linux-gnu",
("macos", "x86_64") => "x86_64-apple-darwin",
("macos", "aarch64") => "aarch64-apple-darwin",
_ => return None,
};
Some(format!("recall-echo-{target}.tar.gz"))
}
fn normalize(tag: &str) -> String {
tag.strip_prefix('v').unwrap_or(tag).to_string()
}
fn validate_tag(tag: &str) -> Result<(), RecallError> {
let charset_ok = !tag.is_empty()
&& tag.len() <= 64
&& tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'));
if charset_ok && !tag.contains("..") {
Ok(())
} else {
Err(RecallError::Other(format!(
"invalid release tag {tag:?} — expected something like v4.3.0"
)))
}
}
fn compare(current: &str, target: &str) -> Option<std::cmp::Ordering> {
if current == target {
return Some(std::cmp::Ordering::Equal);
}
match (parse_tag(target), parse_tag(current)) {
(Some(t), Some(c)) => Some(c.cmp(&t)),
_ => None,
}
}
fn parse_tag(tag: &str) -> Option<(u64, u64, u64)> {
let tag = tag.strip_prefix('v').unwrap_or(tag);
let mut parts = tag.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
fn relation_note(current: &str, target: &str) -> &'static str {
match (parse_tag(current), parse_tag(target)) {
(Some(cur), Some(tgt)) if tgt < cur => " (downgrade)",
_ => "",
}
}
fn resolve_install_path() -> Result<PathBuf, RecallError> {
let exe = std::env::current_exe()
.map_err(|e| RecallError::Other(format!("cannot locate the running binary: {e}")))?;
resolve_from(&exe)
}
fn resolve_from(exe: &Path) -> Result<PathBuf, RecallError> {
std::fs::canonicalize(exe)
.map_err(|e| RecallError::Other(format!("cannot resolve {}: {e}", exe.display())))
}
#[doc(hidden)]
pub fn preflight_writable(dir: &Path) -> Result<(), RecallError> {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let probe = dir.join(format!(
".recall-echo-update-probe.{}.{nonce:08x}",
std::process::id()
));
match open_private_new(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
Ok(())
}
Err(e) => Err(RecallError::Other(format!(
"install directory {} is not writable ({e}) — re-run with sufficient privileges",
dir.display()
))),
}
}
const MAX_ARTIFACT_BYTES: u64 = 500 * 1024 * 1024;
async fn install(
client: &reqwest::Client,
asset_url: &str,
install_path: &Path,
current_version: &str,
target_version: &str,
) -> Result<(), RecallError> {
validate_asset_url(asset_url)?;
let install_dir = install_path
.parent()
.expect("validated by caller: install path has a parent");
sweep_stale_temps(install_dir);
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let stem = format!(".recall-echo-update.{}.{nonce:08x}", std::process::id());
let archive_path = install_dir.join(format!("{stem}.tar.gz"));
let new_bin_path = install_dir.join(format!("{stem}.bin"));
let _guard = TempGuard(vec![archive_path.clone(), new_bin_path.clone()]);
download(client, asset_url, &archive_path).await?;
extract_binary(&archive_path, &new_bin_path)?;
self_check(&new_bin_path, target_version)?;
swap(install_path, &new_bin_path, current_version, target_version)
}
fn sweep_stale_temps(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(rest) = name.strip_prefix(".recall-echo-update") else {
continue;
};
let pid: String = rest
.trim_start_matches(|c: char| !c.is_ascii_digit())
.chars()
.take_while(char::is_ascii_digit)
.collect();
let alive = !pid.is_empty() && Path::new("/proc").join(&pid).exists();
if !alive {
let _ = std::fs::remove_file(entry.path());
}
}
}
struct TempGuard(Vec<PathBuf>);
impl Drop for TempGuard {
fn drop(&mut self) {
for path in &self.0 {
let _ = std::fs::remove_file(path);
}
}
}
async fn download(client: &reqwest::Client, url: &str, dest: &Path) -> Result<(), RecallError> {
use std::io::Write;
let mut resp = client
.get(url)
.send()
.await
.map_err(|e| RecallError::Other(format!("download: {e}")))?;
if !resp.status().is_success() {
return Err(RecallError::Other(format!(
"download returned {} for {url}",
resp.status()
)));
}
let file = open_private_new(dest)?;
let mut writer = std::io::BufWriter::with_capacity(1 << 20, file);
let mut written: u64 = 0;
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| RecallError::Other(format!("download: {e}")))?
{
written += chunk.len() as u64;
if written > MAX_ARTIFACT_BYTES {
return Err(RecallError::Other(format!(
"download exceeded {MAX_ARTIFACT_BYTES} bytes — refusing to continue"
)));
}
writer.write_all(&chunk)?;
}
writer.flush()?;
Ok(())
}
fn open_private_new(path: &Path) -> Result<std::fs::File, RecallError> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| RecallError::Other(format!("cannot create {}: {e}", path.display())))
}
fn extract_binary(archive_path: &Path, dest: &Path) -> Result<(), RecallError> {
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
let file = std::io::BufReader::with_capacity(1 << 20, std::fs::File::open(archive_path)?);
let mut archive = tar::Archive::new(flate2::bufread::GzDecoder::new(file));
for entry in archive
.entries()
.map_err(|e| RecallError::Other(format!("release archive: {e}")))?
{
let mut entry = entry.map_err(|e| RecallError::Other(format!("release archive: {e}")))?;
let is_binary = entry.header().entry_type().is_file()
&& entry
.path()
.ok()
.and_then(|p| p.file_name().map(|n| n == "recall-echo"))
.unwrap_or(false);
if !is_binary {
continue;
}
let mut out = std::io::BufWriter::with_capacity(1 << 20, open_private_new(dest)?);
let copied = std::io::copy(&mut (&mut entry).take(MAX_ARTIFACT_BYTES + 1), &mut out)
.map_err(|e| RecallError::Other(format!("release archive: {e}")))?;
if copied > MAX_ARTIFACT_BYTES {
return Err(RecallError::Other(format!(
"extracted binary exceeded {MAX_ARTIFACT_BYTES} bytes — refusing to continue"
)));
}
out.flush()?;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o700))?;
return Ok(());
}
Err(RecallError::Other(
"release archive contains no `recall-echo` binary".into(),
))
}
#[doc(hidden)]
pub fn self_check(binary: &Path, expected_version: &str) -> Result<(), RecallError> {
let mut attempts = 0;
let output = loop {
match std::process::Command::new(binary)
.arg("--version")
.env_clear()
.output()
{
Ok(output) => break output,
Err(e) if e.raw_os_error() == Some(26) && attempts < 10 => {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => {
return Err(RecallError::Other(format!(
"cannot run {}: {e}",
binary.display()
)))
}
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success()
&& stdout
.split_whitespace()
.any(|word| word == expected_version)
{
return Ok(());
}
Err(RecallError::Other(format!(
"{} --version reported {:?}, expected {expected_version}",
binary.display(),
stdout.trim()
)))
}
#[doc(hidden)]
pub fn swap(
install: &Path,
new_bin: &Path,
current_version: &str,
target_version: &str,
) -> Result<(), RecallError> {
use std::os::unix::fs::PermissionsExt;
let name = install
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| RecallError::Other(format!("bad install path {}", install.display())))?;
let old = install.with_file_name(format!("{name}.old.{current_version}"));
std::fs::set_permissions(new_bin, std::fs::Permissions::from_mode(0o755))?;
std::fs::rename(install, &old)
.map_err(|e| RecallError::Other(format!("cannot move current binary aside: {e}")))?;
if let Err(e) = std::fs::rename(new_bin, install) {
let _ = std::fs::rename(&old, install);
return Err(RecallError::Other(format!(
"cannot move new binary into place ({e}); previous version restored"
)));
}
match self_check(install, target_version) {
Ok(()) => {
if let Err(e) = std::fs::remove_file(&old) {
eprintln!(
"warning: could not remove backup {} ({e}) — safe to delete",
old.display()
);
}
Ok(())
}
Err(check_err) => {
let _ = std::fs::remove_file(install);
match std::fs::rename(&old, install) {
Ok(()) => Err(RecallError::Other(format!(
"installed binary failed verification ({check_err}); previous version restored"
))),
Err(restore_err) => Err(RecallError::Other(format!(
"installed binary failed verification ({check_err}) and restoring failed \
({restore_err}) — previous binary preserved at {}",
old.display()
))),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_name_maps_all_release_targets() {
assert_eq!(
asset_name("linux", "x86_64").as_deref(),
Some("recall-echo-x86_64-unknown-linux-gnu.tar.gz")
);
assert_eq!(
asset_name("linux", "aarch64").as_deref(),
Some("recall-echo-aarch64-unknown-linux-gnu.tar.gz")
);
assert_eq!(
asset_name("macos", "x86_64").as_deref(),
Some("recall-echo-x86_64-apple-darwin.tar.gz")
);
assert_eq!(
asset_name("macos", "aarch64").as_deref(),
Some("recall-echo-aarch64-apple-darwin.tar.gz")
);
}
#[test]
fn asset_name_rejects_unsupported() {
assert_eq!(asset_name("windows", "x86_64"), None);
assert_eq!(asset_name("linux", "riscv64"), None);
}
#[test]
fn parse_tag_strips_v_and_orders() {
assert_eq!(parse_tag("v4.2.0"), Some((4, 2, 0)));
assert_eq!(parse_tag("4.2.0"), Some((4, 2, 0)));
assert!(parse_tag("v4.10.0") > parse_tag("v4.9.9"));
assert!(parse_tag("v5.0.0") > parse_tag("v4.99.99"));
assert_eq!(parse_tag("main"), None);
assert_eq!(parse_tag("v4.2"), None);
assert_eq!(parse_tag(""), None);
}
#[test]
fn relation_note_flags_downgrades_only() {
assert_eq!(relation_note("4.3.0", "4.2.0"), " (downgrade)");
assert_eq!(relation_note("4.2.0", "4.3.0"), "");
assert_eq!(relation_note("4.2.0", "4.2.0"), "");
assert_eq!(relation_note("4.2.0", "garbage"), "");
}
#[test]
fn install_path_follows_symlink() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real-bin");
std::fs::write(&real, b"x").unwrap();
let link = dir.path().join("link-bin");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert_eq!(
resolve_from(&link).unwrap(),
std::fs::canonicalize(&real).unwrap()
);
}
#[test]
fn validate_tag_rejects_path_tricks() {
assert!(validate_tag("v4.3.0").is_ok());
assert!(validate_tag("4.3.0-rc.1+build").is_ok());
assert!(validate_tag("../../../attacker/evil/releases/latest").is_err());
assert!(validate_tag("v4%2e3").is_err());
assert!(validate_tag("v4..3").is_err());
assert!(validate_tag("").is_err());
assert!(validate_tag(&"v".repeat(65)).is_err());
}
#[test]
fn compare_orders_and_flags_unparseable() {
use std::cmp::Ordering;
assert_eq!(compare("4.2.0", "4.3.0"), Some(Ordering::Less));
assert_eq!(compare("4.3.0", "4.2.0"), Some(Ordering::Greater));
assert_eq!(compare("4.3.0", "4.3.0"), Some(Ordering::Equal));
assert_eq!(compare("4.3.0", "garbage"), None);
assert_eq!(compare("garbage", "garbage"), Some(Ordering::Equal));
}
#[test]
fn asset_url_pinned_to_github_hosts() {
assert!(validate_asset_url(
"https://github.com/dnacenta/recall-echo/releases/download/v4.2.0/x.tar.gz"
)
.is_ok());
assert!(validate_asset_url("https://objects.githubusercontent.com/some/asset").is_ok());
assert!(validate_asset_url("http://github.com/insecure").is_err());
assert!(validate_asset_url("https://evil.com/payload.tar.gz").is_err());
assert!(validate_asset_url("https://githubusercontent.com.evil.com/x").is_err());
}
#[test]
fn preflight_rejects_non_directory_target() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
assert!(preflight_writable(&file).is_err());
}
#[test]
fn extract_takes_binary_by_name_and_stages_private() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("asset.tar.gz");
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(&archive_path).unwrap(),
flate2::Compression::fast(),
);
let mut builder = tar::Builder::new(gz);
for (name, body) in [
("LICENSE", b"mpl".as_slice()),
("recall-echo", b"#!/bin/sh\n"),
] {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o777);
header.set_cksum();
builder.append_data(&mut header, name, body).unwrap();
}
builder.into_inner().unwrap().finish().unwrap();
let dest = dir.path().join("staged");
extract_binary(&archive_path, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"#!/bin/sh\n");
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn extract_errors_without_named_binary() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("asset.tar.gz");
let gz = flate2::write::GzEncoder::new(
std::fs::File::create(&archive_path).unwrap(),
flate2::Compression::fast(),
);
let mut builder = tar::Builder::new(gz);
let mut header = tar::Header::new_gnu();
header.set_size(1);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "README", b"x".as_slice())
.unwrap();
builder.into_inner().unwrap().finish().unwrap();
let err = extract_binary(&archive_path, &dir.path().join("staged")).unwrap_err();
assert!(err.to_string().contains("no `recall-echo` binary"));
}
#[test]
fn preflight_rejects_unwritable_dir() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let ro = dir.path().join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
let result = preflight_writable(&ro);
if std::fs::write(ro.join("root-check"), b"").is_err() {
assert!(result.is_err());
}
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(preflight_writable(&ro).is_ok());
}
}