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!("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                        "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!("Version check: current={current} latest={}", info.version);
228                    let headline = extract_headline(&info.notes);
229                    write_version_cache(&info.version, headline.as_deref(), env.paths());
230                    if is_newer(current, &info.version) {
231                        let _ = tx.send(AppEvent::UpdateAvailable {
232                            version: info.version,
233                            headline,
234                        });
235                    }
236                }
237                Err(err) => {
238                    warn!("[external] Version check failed: {err}");
239                }
240            }
241        });
242}
243
244/// Format text as bold, respecting NO_COLOR (resolved from the injected env).
245fn bold(text: &str, no_color: bool) -> String {
246    if no_color {
247        text.to_string()
248    } else {
249        format!("\x1b[1m{}\x1b[0m", text)
250    }
251}
252
253/// Format text as bold purple, respecting NO_COLOR (resolved from the env).
254fn bold_purple(text: &str, no_color: bool) -> String {
255    if no_color {
256        text.to_string()
257    } else {
258        format!("\x1b[1;35m{}\x1b[0m", text)
259    }
260}
261
262/// Install method detected from binary path.
263enum InstallMethod {
264    Homebrew,
265    Cargo,
266    CurlOrManual,
267}
268
269/// Check if exe_path is under a Homebrew Cellar directory.
270/// Validates that the Cellar path ends with a "Cellar" component and
271/// that the binary sits in the expected `.../Cellar/<formula>/.../` structure.
272fn is_homebrew_path(exe_path: &Path, cellar: &Path) -> bool {
273    // Cellar dir must end with "Cellar" component
274    if cellar.file_name().and_then(|n| n.to_str()) != Some("Cellar") {
275        return false;
276    }
277    // Path::starts_with is component-aware: /usr/local won't match /usr/local-bin
278    if !exe_path.starts_with(cellar) {
279        return false;
280    }
281    // Must have at least one component after Cellar (the formula name)
282    exe_path
283        .strip_prefix(cellar)
284        .is_ok_and(|rest| rest.components().count() >= 1)
285}
286
287/// Check if exe_path's parent is exactly <cargo_home>/bin.
288fn is_cargo_path(exe_path: &Path, cargo_home: &Path) -> bool {
289    let cargo_bin = cargo_home.join("bin");
290    exe_path.parent() == Some(cargo_bin.as_path())
291}
292
293/// Detect how purple was installed by checking the binary path against
294/// known package manager directories. Uses Path::starts_with for
295/// component-aware comparison (prevents /usr/local matching /usr/local-bin).
296/// Env vars (HOMEBREW_CELLAR, HOMEBREW_PREFIX, CARGO_HOME) are treated
297/// as hints and validated structurally before trusting. Falls back to
298/// well-known default paths. Fails open to CurlOrManual when uncertain.
299fn detect_install_method(exe_path: &Path, env: &crate::runtime::env::Env) -> InstallMethod {
300    // Homebrew: check HOMEBREW_CELLAR env var first (most specific),
301    // then derive Cellar from HOMEBREW_PREFIX, then fall back to
302    // well-known default Cellar locations
303    if let Some(cellar) = env.var("HOMEBREW_CELLAR") {
304        if is_homebrew_path(exe_path, Path::new(cellar)) {
305            return InstallMethod::Homebrew;
306        }
307    }
308    if let Some(prefix) = env.var("HOMEBREW_PREFIX") {
309        let cellar = std::path::PathBuf::from(prefix).join("Cellar");
310        if is_homebrew_path(exe_path, &cellar) {
311            return InstallMethod::Homebrew;
312        }
313    }
314    // Default Cellar locations (Apple Silicon + Intel + Linuxbrew)
315    for cellar in [
316        "/opt/homebrew/Cellar",
317        "/usr/local/Cellar",
318        "/home/linuxbrew/.linuxbrew/Cellar",
319    ] {
320        if is_homebrew_path(exe_path, Path::new(cellar)) {
321            return InstallMethod::Homebrew;
322        }
323    }
324
325    // Cargo: check CARGO_HOME env var first, then check if parent
326    // is a "bin" dir inside a ".cargo" dir (component-aware fallback)
327    if let Some(cargo_home) = env.var("CARGO_HOME") {
328        if is_cargo_path(exe_path, Path::new(cargo_home)) {
329            return InstallMethod::Cargo;
330        }
331    }
332    if let Some(parent) = exe_path.parent() {
333        if parent.file_name().and_then(|n| n.to_str()) == Some("bin") {
334            if let Some(grandparent) = parent.parent() {
335                if grandparent.file_name().and_then(|n| n.to_str()) == Some(".cargo") {
336                    return InstallMethod::Cargo;
337                }
338            }
339        }
340    }
341
342    InstallMethod::CurlOrManual
343}
344
345/// Detect the update command appropriate for how purple was installed.
346pub fn update_hint(env: &crate::runtime::env::Env) -> &'static str {
347    if !matches!(std::env::consts::OS, "macos" | "linux") {
348        return "cargo install purple-ssh";
349    }
350    if let Ok(exe) = std::env::current_exe() {
351        let path = std::fs::canonicalize(&exe).unwrap_or(exe);
352        return match detect_install_method(&path, env) {
353            InstallMethod::Homebrew => "brew upgrade erickochen/purple/purple",
354            InstallMethod::Cargo => "cargo install purple-ssh",
355            InstallMethod::CurlOrManual => "purple update",
356        };
357    }
358    "purple update"
359}
360
361/// Self-update the purple binary to the latest release.
362pub fn self_update(env: &crate::runtime::env::Env) -> Result<()> {
363    // macOS and Linux only
364    if !matches!(std::env::consts::OS, "macos" | "linux") {
365        anyhow::bail!(
366            "Self-update is available on macOS and Linux only.\n  \
367             Update via: cargo install purple-ssh"
368        );
369    }
370
371    let no_color = env.no_color();
372    println!(
373        "{}",
374        crate::messages::update::header(&bold("purple.", no_color))
375    );
376
377    // Resolve current binary path
378    let exe_path = std::env::current_exe().context("Failed to detect binary path")?;
379    let exe_path = std::fs::canonicalize(&exe_path).unwrap_or(exe_path);
380    println!("{}", crate::messages::update::binary_path(&exe_path));
381
382    // Detect package manager installations
383    match detect_install_method(&exe_path, env) {
384        InstallMethod::Homebrew => {
385            anyhow::bail!(
386                "purple appears to be installed via Homebrew.\n  \
387                 Update with: brew upgrade erickochen/purple/purple"
388            );
389        }
390        InstallMethod::Cargo => {
391            anyhow::bail!(
392                "purple appears to be installed via cargo.\n  \
393                 Update with: cargo install purple-ssh"
394            );
395        }
396        InstallMethod::CurlOrManual => {}
397    }
398
399    // Fetch latest version (needs redirects for GitHub release asset downloads)
400    print!("{}", crate::messages::update::STEP_CHECKING);
401    let agent = ureq::Agent::config_builder()
402        .timeout_global(Some(std::time::Duration::from_secs(30)))
403        .build()
404        .new_agent();
405    let info = check_latest_release(&agent)?;
406    let latest = info.version;
407    let current = current_version();
408
409    if !is_newer(current, &latest) {
410        println!("{}", crate::messages::update::already_on(current));
411        return Ok(());
412    }
413
414    println!("{}", crate::messages::update::available(&latest, current));
415    info!("[purple] Update started: {current} -> {latest}");
416
417    // Detect target
418    let target = match (std::env::consts::ARCH, std::env::consts::OS) {
419        ("aarch64", "macos") => "aarch64-apple-darwin",
420        ("x86_64", "macos") => "x86_64-apple-darwin",
421        ("aarch64", "linux") => "aarch64-unknown-linux-gnu",
422        ("x86_64", "linux") => "x86_64-unknown-linux-gnu",
423        (arch, os) => anyhow::bail!("Unsupported platform: {}-{}", arch, os),
424    };
425
426    // Check we can write to the binary location
427    let parent = exe_path
428        .parent()
429        .context("Binary has no parent directory")?;
430
431    // Warn when running via sudo — creates root-owned cache files
432    if env.var("SUDO_USER").is_some() {
433        eprintln!(
434            "{}",
435            crate::messages::update::sudo_warning_line(&bold("!", no_color))
436        );
437    }
438
439    if !is_writable(parent) {
440        anyhow::bail!(
441            "No write permission to {}.\n  Check directory permissions or run with elevated privileges.",
442            parent.display()
443        );
444    }
445
446    // Clean up stale staged binaries from interrupted previous updates
447    clean_stale_staged(parent);
448
449    // Set up temp directory (create_dir fails if path exists, preventing symlink attacks)
450    let tmp_dir = std::env::temp_dir().join(format!(
451        "purple_update_{}_{}",
452        std::process::id(),
453        std::time::SystemTime::now()
454            .duration_since(std::time::UNIX_EPOCH)
455            .unwrap_or_default()
456            .as_nanos()
457    ));
458    std::fs::create_dir(&tmp_dir).context("Failed to create temp directory")?;
459
460    #[cfg(unix)]
461    {
462        use std::os::unix::fs::PermissionsExt;
463        std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))
464            .context("Failed to set temp directory permissions")?;
465    }
466
467    // Ensure cleanup on any exit path
468    let _cleanup = TempCleanup(&tmp_dir);
469
470    let tarball_name = format!("purple-{}-{}.tar.gz", latest, target);
471    let base_url = format!(
472        "https://github.com/erickochen/purple/releases/download/v{}",
473        latest
474    );
475
476    // Download tarball
477    print!("{}", crate::messages::update::step_downloading(&latest));
478    let tarball_path = tmp_dir.join(&tarball_name);
479    download_file(
480        &agent,
481        &format!("{}/{}", base_url, tarball_name),
482        &tarball_path,
483    )?;
484
485    // Download checksum
486    let sha_path = tmp_dir.join(format!("{}.sha256", tarball_name));
487    download_file(
488        &agent,
489        &format!("{}/{}.sha256", base_url, tarball_name),
490        &sha_path,
491    )?;
492    println!("{}", crate::messages::update::DONE);
493
494    // Verify checksum
495    print!("{}", crate::messages::update::STEP_VERIFYING_CHECKSUM);
496    verify_checksum(&tarball_path, &sha_path)?;
497    println!("{}", crate::messages::update::CHECKSUM_OK);
498
499    // Extract
500    print!("{}", crate::messages::update::STEP_INSTALLING);
501    let status = std::process::Command::new("tar")
502        .arg("-xzf")
503        .arg(&tarball_path)
504        .arg("-C")
505        .arg(&tmp_dir)
506        .status()
507        .context("Failed to run tar")?;
508    if !status.success() {
509        anyhow::bail!("tar extraction failed");
510    }
511
512    let new_binary = tmp_dir.join("purple");
513    if !new_binary.exists() {
514        anyhow::bail!("Binary not found in archive");
515    }
516
517    // Atomic replacement: stage new binary in the same directory via O_EXCL
518    // (prevents symlink attacks), then rename over the target (atomic within
519    // the same filesystem)
520    let staged_path = parent.join(format!(".purple_new_{}", std::process::id()));
521    {
522        use std::io::Write;
523        let source = std::fs::read(&new_binary).context("Failed to read new binary")?;
524        let mut dest = std::fs::OpenOptions::new()
525            .write(true)
526            .create_new(true) // O_EXCL: fails if path exists (prevents symlink following)
527            .open(&staged_path)
528            .context("Failed to create staged binary")?;
529        dest.write_all(&source)
530            .context("Failed to write staged binary")?;
531    }
532
533    #[cfg(unix)]
534    {
535        use std::os::unix::fs::PermissionsExt;
536        std::fs::set_permissions(&staged_path, std::fs::Permissions::from_mode(0o755))
537            .context("Failed to set permissions")?;
538    }
539
540    if let Err(e) = std::fs::rename(&staged_path, &exe_path) {
541        // Clean up staged file on failure
542        let _ = std::fs::remove_file(&staged_path);
543        return Err(e).context("Failed to replace binary");
544    }
545
546    println!("{}", crate::messages::update::DONE);
547    info!("[purple] Update completed: {latest}");
548    println!(
549        "{}",
550        crate::messages::update::installed_at(
551            &bold_purple(&format!("purple v{}", latest), no_color),
552            &exe_path,
553        )
554    );
555
556    println!("{}", crate::messages::update::whats_new_hint_indented());
557    println!();
558
559    Ok(())
560}
561
562/// Download a file from a URL.
563fn download_file(agent: &ureq::Agent, url: &str, dest: &Path) -> Result<()> {
564    let mut resp = agent
565        .get(url)
566        .call()
567        .with_context(|| format!("Failed to download {}", url))?;
568
569    let mut bytes = Vec::new();
570    resp.body_mut()
571        .as_reader()
572        .take(100 * 1024 * 1024) // 100 MB limit
573        .read_to_end(&mut bytes)
574        .context("Failed to read download")?;
575
576    if bytes.is_empty() {
577        anyhow::bail!("Empty response from {}", url);
578    }
579
580    crate::fs_util::atomic_write(dest, &bytes).context("Failed to write file")?;
581    Ok(())
582}
583
584/// Verify SHA256 checksum of a file using the sha2 crate (no external tools).
585fn verify_checksum(file: &Path, sha_file: &Path) -> Result<()> {
586    let expected = std::fs::read_to_string(sha_file).context("Failed to read checksum file")?;
587    let expected = expected
588        .split_whitespace()
589        .next()
590        .context("Empty checksum file")?;
591
592    use sha2::{Digest, Sha256};
593    let bytes = std::fs::read(file).context("Failed to read file for checksum")?;
594    let actual = format!("{:x}", Sha256::digest(&bytes));
595
596    if expected != actual {
597        anyhow::bail!(
598            "Checksum mismatch.\n    Expected: {}\n    Got:      {}",
599            expected,
600            actual
601        );
602    }
603
604    Ok(())
605}
606
607/// Remove stale `.purple_new_*` files from previous interrupted updates.
608fn clean_stale_staged(dir: &Path) {
609    if let Ok(entries) = std::fs::read_dir(dir) {
610        for entry in entries.flatten() {
611            if let Some(name) = entry.file_name().to_str() {
612                if name.starts_with(".purple_new_") {
613                    let _ = std::fs::remove_file(entry.path());
614                }
615            }
616        }
617    }
618}
619
620/// Check if a directory is writable.
621fn is_writable(path: &Path) -> bool {
622    let probe = path.join(format!(".purple_write_test_{}", std::process::id()));
623    if std::fs::File::create(&probe).is_ok() {
624        let _ = std::fs::remove_file(&probe);
625        true
626    } else {
627        false
628    }
629}
630
631/// RAII guard that removes a temp directory on drop.
632struct TempCleanup<'a>(&'a Path);
633
634impl Drop for TempCleanup<'_> {
635    fn drop(&mut self) {
636        let _ = std::fs::remove_dir_all(self.0);
637    }
638}
639
640#[cfg(test)]
641#[path = "update_tests.rs"]
642mod tests;