Skip to main content

purple_ssh/
update.rs

1use std::io::Read;
2use std::path::Path;
3use std::sync::mpsc;
4
5use anyhow::{Context, Result};
6use log::{debug, info, warn};
7
8use crate::event::AppEvent;
9
10/// Current compiled-in version from Cargo.toml.
11pub fn current_version() -> &'static str {
12    env!("CARGO_PKG_VERSION")
13}
14
15/// Max bytes kept from a release-notes headline before caching.
16/// Bounds attacker-controlled input written to ~/.purple/last_version_check.
17const HEADLINE_MAX_BYTES: usize = 200;
18
19/// Extract a one-line headline from release notes for the TUI update badge.
20/// Takes the first non-empty content line, strips leading `- ` bullet marker,
21/// and truncates to `HEADLINE_MAX_BYTES` on a char boundary.
22fn extract_headline(notes: &str) -> Option<String> {
23    let line = notes
24        .lines()
25        .map(|l| l.trim())
26        .find(|l| !l.is_empty() && !l.starts_with('#'))?;
27    let trimmed = line.strip_prefix("- ").unwrap_or(line);
28    if trimmed.len() <= HEADLINE_MAX_BYTES {
29        return Some(trimmed.to_string());
30    }
31    let mut cut = HEADLINE_MAX_BYTES;
32    while cut > 0 && !trimmed.is_char_boundary(cut) {
33        cut -= 1;
34    }
35    Some(trimmed[..cut].to_string())
36}
37
38/// Parse a semver string "X.Y.Z" into a tuple.
39fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
40    let mut parts = v.splitn(3, '.');
41    let major = parts.next()?.parse().ok()?;
42    let minor = parts.next()?.parse().ok()?;
43    let patch = parts.next()?.parse().ok()?;
44    Some((major, minor, patch))
45}
46
47/// Returns true if `latest` is strictly newer than `current`.
48fn is_newer(current: &str, latest: &str) -> bool {
49    match (parse_version(current), parse_version(latest)) {
50        (Some(c), Some(l)) => l > c,
51        _ => false,
52    }
53}
54
55/// Release info extracted from GitHub API response.
56struct ReleaseInfo {
57    version: String,
58    /// Release notes body (markdown). May be empty.
59    notes: String,
60}
61
62/// Extract version string and release notes from GitHub release JSON.
63fn extract_release_info(json: &serde_json::Value) -> Result<ReleaseInfo> {
64    let tag = json["tag_name"]
65        .as_str()
66        .context("Missing tag_name in release")?;
67
68    let version = tag.strip_prefix('v').unwrap_or(tag);
69
70    if parse_version(version).is_none() {
71        anyhow::bail!("Invalid version format: {}", version);
72    }
73
74    let notes = json["body"].as_str().unwrap_or("").to_string();
75
76    Ok(ReleaseInfo {
77        version: version.to_string(),
78        notes,
79    })
80}
81
82/// Fetch the latest release info from GitHub.
83fn check_latest_release(agent: &ureq::Agent) -> Result<ReleaseInfo> {
84    let mut resp = agent
85        .get("https://api.github.com/repos/erickochen/purple/releases/latest")
86        .header("Accept", "application/vnd.github+json")
87        .header("User-Agent", &format!("purple-ssh/{}", current_version()))
88        .call()
89        .context("Failed to fetch latest release. GitHub may be rate-limited.")?;
90
91    let mut body = Vec::new();
92    resp.body_mut()
93        .as_reader()
94        .take(1_048_576) // 1 MB limit for API response
95        .read_to_end(&mut body)
96        .context("Failed to read release JSON")?;
97
98    let json: serde_json::Value =
99        serde_json::from_slice(&body).context("Failed to parse release JSON")?;
100
101    extract_release_info(&json)
102}
103
104/// TTL for version check cache (1 hour).
105const VERSION_CHECK_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60);
106
107/// Cached version info: version string and optional headline.
108#[derive(Debug, PartialEq)]
109struct CachedVersion {
110    version: String,
111    headline: Option<String>,
112}
113
114/// Parse cache file content and determine if a newer version is available.
115/// Cache format: `timestamp\nversion\nheadline\n` (headline may be empty).
116/// Returns `Some(Some(cached))` if cache is fresh and a newer version exists,
117/// `Some(None)` if cache is fresh and we are up-to-date,
118/// `None` if cache content is corrupt, expired or unparseable.
119fn parse_version_cache(
120    content: &str,
121    now_secs: u64,
122    current: &str,
123) -> Option<Option<CachedVersion>> {
124    let mut lines = content.lines();
125    let timestamp: u64 = lines.next()?.parse().ok()?;
126    let version = lines.next()?.to_string();
127    let headline = lines
128        .next()
129        .map(|s| s.to_string())
130        .filter(|s| !s.is_empty());
131
132    if version.is_empty() || parse_version(&version).is_none() {
133        return None; // Corrupt version string
134    }
135
136    if now_secs.saturating_sub(timestamp) > VERSION_CHECK_TTL.as_secs() {
137        return None; // Cache expired
138    }
139
140    if is_newer(current, &version) {
141        Some(Some(CachedVersion { version, headline }))
142    } else {
143        Some(None) // Up-to-date, no API call needed
144    }
145}
146
147/// Read cached version check result from ~/.purple/last_version_check.
148/// Returns `Some(Some(cached))` if cache is fresh and a newer version exists,
149/// `Some(None)` if cache is fresh and we are up-to-date,
150/// `None` if cache is missing, corrupt or expired.
151fn read_cached_version(
152    paths: Option<&crate::runtime::env::Paths>,
153) -> Option<Option<CachedVersion>> {
154    let path = paths?.last_version_check();
155    let content = std::fs::read_to_string(&path).ok()?;
156    let now = std::time::SystemTime::now()
157        .duration_since(std::time::UNIX_EPOCH)
158        .ok()?
159        .as_secs();
160    parse_version_cache(&content, now, current_version())
161}
162
163/// Write version check result to ~/.purple/last_version_check.
164fn write_version_cache(
165    version: &str,
166    headline: Option<&str>,
167    paths: Option<&crate::runtime::env::Paths>,
168) {
169    let Some(paths) = paths else {
170        return;
171    };
172    let dir = paths.purple_dir();
173    if let Err(e) = std::fs::create_dir_all(&dir) {
174        debug!("[config] Failed to create version cache directory: {e}");
175        return;
176    }
177    let now = std::time::SystemTime::now()
178        .duration_since(std::time::UNIX_EPOCH)
179        .unwrap_or_default()
180        .as_secs();
181    let hl = headline.unwrap_or("");
182    let content = format!("{}\n{}\n{}\n", now, version, hl);
183    if let Err(e) = crate::fs_util::atomic_write(&paths.last_version_check(), content.as_bytes()) {
184        debug!("[config] Failed to write version cache: {e}");
185    }
186}
187
188/// Spawn a background thread to check for updates. Sends an event if a newer version exists.
189/// Uses a local cache (~/.purple/last_version_check) with a 1h TTL to avoid unnecessary
190/// GitHub API calls on frequent startup. Silently does nothing on any error.
191pub fn spawn_version_check(
192    tx: mpsc::Sender<AppEvent>,
193    env: std::sync::Arc<crate::runtime::env::Env>,
194) {
195    let _ = std::thread::Builder::new()
196        .name("version-check".to_string())
197        .spawn(move || {
198            debug!("[external] Version check started");
199            // Check cache first — skip API call if fresh result exists
200            match read_cached_version(env.paths()) {
201                Some(Some(cached)) => {
202                    debug!(
203                        "[external] Version check: current={} latest={}",
204                        current_version(),
205                        cached.version
206                    );
207                    let _ = tx.send(AppEvent::UpdateAvailable {
208                        version: cached.version,
209                        headline: cached.headline,
210                    });
211                    return;
212                }
213                Some(None) => return, // Up-to-date, cache still fresh
214                None => {}            // Cache missing or expired, fetch
215            }
216
217            // Short timeout: fire-and-forget background check,
218            // don't tie up thread resources for 30s like the provider agent
219            let agent = ureq::Agent::config_builder()
220                .timeout_global(Some(std::time::Duration::from_secs(5)))
221                .build()
222                .new_agent();
223
224            match check_latest_release(&agent) {
225                Ok(info) => {
226                    let current = current_version();
227                    debug!(
228                        "[external] Version check: current={current} latest={}",
229                        info.version
230                    );
231                    let headline = extract_headline(&info.notes);
232                    write_version_cache(&info.version, headline.as_deref(), env.paths());
233                    if is_newer(current, &info.version) {
234                        let _ = tx.send(AppEvent::UpdateAvailable {
235                            version: info.version,
236                            headline,
237                        });
238                    }
239                }
240                Err(err) => {
241                    warn!("[external] Version check failed: {err}");
242                }
243            }
244        });
245}
246
247/// Format text as bold, respecting NO_COLOR (resolved from the injected env).
248fn bold(text: &str, no_color: bool) -> String {
249    if no_color {
250        text.to_string()
251    } else {
252        format!("\x1b[1m{}\x1b[0m", text)
253    }
254}
255
256/// Format text as bold purple, respecting NO_COLOR (resolved from the env).
257fn bold_purple(text: &str, no_color: bool) -> String {
258    if no_color {
259        text.to_string()
260    } else {
261        format!("\x1b[1;35m{}\x1b[0m", text)
262    }
263}
264
265/// Install method detected from binary path.
266enum InstallMethod {
267    Homebrew,
268    Cargo,
269    CurlOrManual,
270}
271
272/// Check if exe_path is under a Homebrew Cellar directory.
273/// Validates that the Cellar path ends with a "Cellar" component and
274/// that the binary sits in the expected `.../Cellar/<formula>/.../` structure.
275fn is_homebrew_path(exe_path: &Path, cellar: &Path) -> bool {
276    // Cellar dir must end with "Cellar" component
277    if cellar.file_name().and_then(|n| n.to_str()) != Some("Cellar") {
278        return false;
279    }
280    // Path::starts_with is component-aware: /usr/local won't match /usr/local-bin
281    if !exe_path.starts_with(cellar) {
282        return false;
283    }
284    // Must have at least one component after Cellar (the formula name)
285    exe_path
286        .strip_prefix(cellar)
287        .is_ok_and(|rest| rest.components().count() >= 1)
288}
289
290/// Check if exe_path's parent is exactly <cargo_home>/bin.
291fn is_cargo_path(exe_path: &Path, cargo_home: &Path) -> bool {
292    let cargo_bin = cargo_home.join("bin");
293    exe_path.parent() == Some(cargo_bin.as_path())
294}
295
296/// Detect how purple was installed by checking the binary path against
297/// known package manager directories. Uses Path::starts_with for
298/// component-aware comparison (prevents /usr/local matching /usr/local-bin).
299/// Env vars (HOMEBREW_CELLAR, HOMEBREW_PREFIX, CARGO_HOME) are treated
300/// as hints and validated structurally before trusting. Falls back to
301/// well-known default paths. Fails open to CurlOrManual when uncertain.
302fn detect_install_method(exe_path: &Path, env: &crate::runtime::env::Env) -> InstallMethod {
303    // Homebrew: check HOMEBREW_CELLAR env var first (most specific),
304    // then derive Cellar from HOMEBREW_PREFIX, then fall back to
305    // well-known default Cellar locations
306    if let Some(cellar) = env.var("HOMEBREW_CELLAR") {
307        if is_homebrew_path(exe_path, Path::new(cellar)) {
308            return InstallMethod::Homebrew;
309        }
310    }
311    if let Some(prefix) = env.var("HOMEBREW_PREFIX") {
312        let cellar = std::path::PathBuf::from(prefix).join("Cellar");
313        if is_homebrew_path(exe_path, &cellar) {
314            return InstallMethod::Homebrew;
315        }
316    }
317    // Default Cellar locations (Apple Silicon + Intel + Linuxbrew)
318    for cellar in [
319        "/opt/homebrew/Cellar",
320        "/usr/local/Cellar",
321        "/home/linuxbrew/.linuxbrew/Cellar",
322    ] {
323        if is_homebrew_path(exe_path, Path::new(cellar)) {
324            return InstallMethod::Homebrew;
325        }
326    }
327
328    // Cargo: check CARGO_HOME env var first, then check if parent
329    // is a "bin" dir inside a ".cargo" dir (component-aware fallback)
330    if let Some(cargo_home) = env.var("CARGO_HOME") {
331        if is_cargo_path(exe_path, Path::new(cargo_home)) {
332            return InstallMethod::Cargo;
333        }
334    }
335    if let Some(parent) = exe_path.parent() {
336        if parent.file_name().and_then(|n| n.to_str()) == Some("bin") {
337            if let Some(grandparent) = parent.parent() {
338                if grandparent.file_name().and_then(|n| n.to_str()) == Some(".cargo") {
339                    return InstallMethod::Cargo;
340                }
341            }
342        }
343    }
344
345    InstallMethod::CurlOrManual
346}
347
348/// Detect the update command appropriate for how purple was installed.
349pub fn update_hint(env: &crate::runtime::env::Env) -> &'static str {
350    if !matches!(std::env::consts::OS, "macos" | "linux") {
351        return "cargo install purple-ssh";
352    }
353    if let Ok(exe) = std::env::current_exe() {
354        let path = std::fs::canonicalize(&exe).unwrap_or(exe);
355        return match detect_install_method(&path, env) {
356            InstallMethod::Homebrew => "brew upgrade erickochen/purple/purple",
357            InstallMethod::Cargo => "cargo install purple-ssh",
358            InstallMethod::CurlOrManual => "purple update",
359        };
360    }
361    "purple update"
362}
363
364/// Self-update the purple binary to the latest release.
365pub fn self_update(env: &crate::runtime::env::Env) -> Result<()> {
366    // macOS and Linux only
367    if !matches!(std::env::consts::OS, "macos" | "linux") {
368        anyhow::bail!(
369            "Self-update is available on macOS and Linux only.\n  \
370             Update via: cargo install purple-ssh"
371        );
372    }
373
374    let no_color = env.no_color();
375    println!(
376        "{}",
377        crate::messages::update::header(&bold("purple.", no_color))
378    );
379
380    // Resolve current binary path
381    let exe_path = std::env::current_exe().context("Failed to detect binary path")?;
382    let exe_path = std::fs::canonicalize(&exe_path).unwrap_or(exe_path);
383    println!("{}", crate::messages::update::binary_path(&exe_path));
384
385    // Detect package manager installations
386    match detect_install_method(&exe_path, env) {
387        InstallMethod::Homebrew => {
388            anyhow::bail!(
389                "purple appears to be installed via Homebrew.\n  \
390                 Update with: brew upgrade erickochen/purple/purple"
391            );
392        }
393        InstallMethod::Cargo => {
394            anyhow::bail!(
395                "purple appears to be installed via cargo.\n  \
396                 Update with: cargo install purple-ssh"
397            );
398        }
399        InstallMethod::CurlOrManual => {}
400    }
401
402    // Fetch latest version (needs redirects for GitHub release asset downloads)
403    print!("{}", crate::messages::update::STEP_CHECKING);
404    let agent = ureq::Agent::config_builder()
405        .timeout_global(Some(std::time::Duration::from_secs(30)))
406        .build()
407        .new_agent();
408    let info = check_latest_release(&agent)?;
409    let latest = info.version;
410    let current = current_version();
411
412    if !is_newer(current, &latest) {
413        println!("{}", crate::messages::update::already_on(current));
414        return Ok(());
415    }
416
417    println!("{}", crate::messages::update::available(&latest, current));
418    info!("[purple] Update started: {current} -> {latest}");
419
420    // Detect target
421    let target = match (std::env::consts::ARCH, std::env::consts::OS) {
422        ("aarch64", "macos") => "aarch64-apple-darwin",
423        ("x86_64", "macos") => "x86_64-apple-darwin",
424        ("aarch64", "linux") => "aarch64-unknown-linux-gnu",
425        ("x86_64", "linux") => "x86_64-unknown-linux-gnu",
426        (arch, os) => anyhow::bail!("Unsupported platform: {}-{}", arch, os),
427    };
428
429    // Check we can write to the binary location
430    let parent = exe_path
431        .parent()
432        .context("Binary has no parent directory")?;
433
434    // Warn when running via sudo — creates root-owned cache files
435    if env.var("SUDO_USER").is_some() {
436        eprintln!(
437            "{}",
438            crate::messages::update::sudo_warning_line(&bold("!", no_color))
439        );
440    }
441
442    if !is_writable(parent) {
443        anyhow::bail!(
444            "No write permission to {}.\n  Check directory permissions or run with elevated privileges.",
445            parent.display()
446        );
447    }
448
449    // Clean up stale staged binaries from interrupted previous updates
450    clean_stale_staged(parent);
451
452    // Set up temp directory (create_dir fails if path exists, preventing symlink attacks)
453    let tmp_dir = std::env::temp_dir().join(format!(
454        "purple_update_{}_{}",
455        std::process::id(),
456        std::time::SystemTime::now()
457            .duration_since(std::time::UNIX_EPOCH)
458            .unwrap_or_default()
459            .as_nanos()
460    ));
461    std::fs::create_dir(&tmp_dir).context("Failed to create temp directory")?;
462
463    #[cfg(unix)]
464    {
465        use std::os::unix::fs::PermissionsExt;
466        std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))
467            .context("Failed to set temp directory permissions")?;
468    }
469
470    // Ensure cleanup on any exit path
471    let _cleanup = TempCleanup(&tmp_dir);
472
473    let tarball_name = format!("purple-{}-{}.tar.gz", latest, target);
474    let base_url = format!(
475        "https://github.com/erickochen/purple/releases/download/v{}",
476        latest
477    );
478
479    // Download tarball
480    print!("{}", crate::messages::update::step_downloading(&latest));
481    let tarball_path = tmp_dir.join(&tarball_name);
482    download_file(
483        &agent,
484        &format!("{}/{}", base_url, tarball_name),
485        &tarball_path,
486    )?;
487
488    // Download checksum
489    let sha_path = tmp_dir.join(format!("{}.sha256", tarball_name));
490    download_file(
491        &agent,
492        &format!("{}/{}.sha256", base_url, tarball_name),
493        &sha_path,
494    )?;
495    println!("{}", crate::messages::update::DONE);
496
497    // Verify checksum
498    print!("{}", crate::messages::update::STEP_VERIFYING_CHECKSUM);
499    verify_checksum(&tarball_path, &sha_path)?;
500    println!("{}", crate::messages::update::CHECKSUM_OK);
501
502    // Extract
503    print!("{}", crate::messages::update::STEP_INSTALLING);
504    let status = std::process::Command::new("tar")
505        .arg("-xzf")
506        .arg(&tarball_path)
507        .arg("-C")
508        .arg(&tmp_dir)
509        .status()
510        .context("Failed to run tar")?;
511    if !status.success() {
512        anyhow::bail!("tar extraction failed");
513    }
514
515    let new_binary = tmp_dir.join("purple");
516    if !new_binary.exists() {
517        anyhow::bail!("Binary not found in archive");
518    }
519
520    // Atomic replacement: stage new binary in the same directory via O_EXCL
521    // (prevents symlink attacks), then rename over the target (atomic within
522    // the same filesystem)
523    let staged_path = parent.join(format!(".purple_new_{}", std::process::id()));
524    {
525        use std::io::Write;
526        let source = std::fs::read(&new_binary).context("Failed to read new binary")?;
527        let mut dest = std::fs::OpenOptions::new()
528            .write(true)
529            .create_new(true) // O_EXCL: fails if path exists (prevents symlink following)
530            .open(&staged_path)
531            .context("Failed to create staged binary")?;
532        dest.write_all(&source)
533            .context("Failed to write staged binary")?;
534    }
535
536    #[cfg(unix)]
537    {
538        use std::os::unix::fs::PermissionsExt;
539        std::fs::set_permissions(&staged_path, std::fs::Permissions::from_mode(0o755))
540            .context("Failed to set permissions")?;
541    }
542
543    if let Err(e) = std::fs::rename(&staged_path, &exe_path) {
544        // Clean up staged file on failure
545        let _ = std::fs::remove_file(&staged_path);
546        return Err(e).context("Failed to replace binary");
547    }
548
549    println!("{}", crate::messages::update::DONE);
550    info!("[purple] Update completed: {latest}");
551    println!(
552        "{}",
553        crate::messages::update::installed_at(
554            &bold_purple(&format!("purple v{}", latest), no_color),
555            &exe_path,
556        )
557    );
558
559    println!("{}", crate::messages::update::whats_new_hint_indented());
560    println!();
561
562    Ok(())
563}
564
565/// Download a file from a URL.
566fn download_file(agent: &ureq::Agent, url: &str, dest: &Path) -> Result<()> {
567    let mut resp = agent
568        .get(url)
569        .call()
570        .with_context(|| format!("Failed to download {}", url))?;
571
572    let mut bytes = Vec::new();
573    resp.body_mut()
574        .as_reader()
575        .take(100 * 1024 * 1024) // 100 MB limit
576        .read_to_end(&mut bytes)
577        .context("Failed to read download")?;
578
579    if bytes.is_empty() {
580        anyhow::bail!("Empty response from {}", url);
581    }
582
583    crate::fs_util::atomic_write(dest, &bytes).context("Failed to write file")?;
584    Ok(())
585}
586
587/// Verify SHA256 checksum of a file using the sha2 crate (no external tools).
588fn verify_checksum(file: &Path, sha_file: &Path) -> Result<()> {
589    let expected = std::fs::read_to_string(sha_file).context("Failed to read checksum file")?;
590    let expected = expected
591        .split_whitespace()
592        .next()
593        .context("Empty checksum file")?;
594
595    use sha2::{Digest, Sha256};
596    let bytes = std::fs::read(file).context("Failed to read file for checksum")?;
597    let actual = format!("{:x}", Sha256::digest(&bytes));
598
599    if expected != actual {
600        anyhow::bail!(
601            "Checksum mismatch.\n    Expected: {}\n    Got:      {}",
602            expected,
603            actual
604        );
605    }
606
607    Ok(())
608}
609
610/// Remove stale `.purple_new_*` files from previous interrupted updates.
611fn clean_stale_staged(dir: &Path) {
612    if let Ok(entries) = std::fs::read_dir(dir) {
613        for entry in entries.flatten() {
614            if let Some(name) = entry.file_name().to_str() {
615                if name.starts_with(".purple_new_") {
616                    let _ = std::fs::remove_file(entry.path());
617                }
618            }
619        }
620    }
621}
622
623/// Check if a directory is writable.
624fn is_writable(path: &Path) -> bool {
625    let probe = path.join(format!(".purple_write_test_{}", std::process::id()));
626    if std::fs::File::create(&probe).is_ok() {
627        let _ = std::fs::remove_file(&probe);
628        true
629    } else {
630        false
631    }
632}
633
634/// RAII guard that removes a temp directory on drop.
635struct TempCleanup<'a>(&'a Path);
636
637impl Drop for TempCleanup<'_> {
638    fn drop(&mut self) {
639        let _ = std::fs::remove_dir_all(self.0);
640    }
641}
642
643#[cfg(test)]
644#[path = "update_tests.rs"]
645mod tests;