Skip to main content

research_master/utils/
update.rs

1//! Update utilities for self-updating the CLI.
2
3use anyhow::{bail, Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[cfg(unix)]
9use std::os::unix::fs::PermissionsExt;
10
11/// Check if an external binary is available in PATH
12fn is_command_available(name: &str) -> bool {
13    Command::new(name)
14        .arg("--version")
15        .output()
16        .map(|o| o.status.success())
17        .unwrap_or(false)
18}
19
20/// Information about the current installation method
21#[derive(Debug, Clone)]
22pub enum InstallationMethod {
23    /// Installed via Homebrew
24    Homebrew {
25        /// Path to the Homebrew binary
26        path: PathBuf,
27    },
28    /// Installed via cargo install
29    Cargo {
30        /// Installation directory
31        path: PathBuf,
32    },
33    /// Directly installed binary
34    Direct {
35        /// Path to the binary
36        path: PathBuf,
37    },
38    /// Unknown installation method
39    Unknown,
40}
41
42/// Detect how the tool was installed
43pub fn detect_installation() -> InstallationMethod {
44    let exe_path = match std::env::current_exe() {
45        Ok(path) => path,
46        Err(_) => return InstallationMethod::Unknown,
47    };
48
49    // Check if running from Homebrew prefix
50    if let Ok(homebrew_prefix) = std::env::var("HOMEBREW_PREFIX") {
51        let homebrew_bin = PathBuf::from(homebrew_prefix)
52            .join("bin")
53            .join("research-master");
54        if exe_path == homebrew_bin
55            || exe_path.starts_with(homebrew_bin.parent().unwrap_or(&homebrew_bin))
56        {
57            return InstallationMethod::Homebrew { path: exe_path };
58        }
59    }
60
61    // Check if installed via cargo (check if ~/.cargo/bin is in the path)
62    if let Ok(cargo_home) = std::env::var("CARGO_HOME") {
63        let cargo_bin = PathBuf::from(cargo_home)
64            .join("bin")
65            .join("research-master");
66        if exe_path == cargo_bin {
67            return InstallationMethod::Cargo { path: exe_path };
68        }
69    }
70
71    // Check common Homebrew locations
72    let homebrew_paths = [
73        PathBuf::from("/opt/homebrew/bin/research-master"),
74        PathBuf::from("/usr/local/bin/research-master"),
75        PathBuf::from("/home/linuxbrew/.linuxbrew/bin/research-master"),
76    ];
77
78    for hb_path in &homebrew_paths {
79        if exe_path == *hb_path {
80            return InstallationMethod::Homebrew { path: exe_path };
81        }
82    }
83
84    InstallationMethod::Direct { path: exe_path }
85}
86
87/// Get installation-specific update instructions
88pub fn get_update_instructions(method: &InstallationMethod) -> String {
89    match method {
90        InstallationMethod::Homebrew { .. } => {
91            "You seem to have installed via Homebrew. Run:\n  brew upgrade research-master".to_string()
92        }
93        InstallationMethod::Cargo { .. } => {
94            "You seem to have installed via cargo. Run:\n  cargo install research-master".to_string()
95        }
96        InstallationMethod::Direct { .. } => {
97            "I'll download and install the latest version for you.".to_string()
98        }
99        InstallationMethod::Unknown => {
100            "Unable to detect installation method.\n\nIf you installed via:\n  - Homebrew: run 'brew upgrade research-master'\n  - cargo: run 'cargo install research-master'\n  - Direct download: I'll download the latest binary".to_string()
101        }
102    }
103}
104
105/// GitHub release information
106#[derive(Debug, Clone)]
107pub struct ReleaseInfo {
108    /// Tag name (e.g., "v0.1.5")
109    pub tag_name: String,
110    /// Version number without 'v' prefix
111    pub version: String,
112    /// Release notes/body
113    pub body: String,
114    /// Published date
115    pub published_at: String,
116    /// Array of assets with download URLs
117    pub assets: Vec<ReleaseAsset>,
118}
119
120/// A single release asset
121#[derive(Debug, Clone)]
122pub struct ReleaseAsset {
123    /// Asset name (e.g., "research-master-x86_64-apple-darwin.tar.gz")
124    pub name: String,
125    /// Download URL
126    pub download_url: String,
127}
128
129/// Fetch the latest release information from GitHub
130pub async fn fetch_latest_release() -> Result<ReleaseInfo> {
131    let client = reqwest::Client::new();
132    let response = client
133        .get("https://api.github.com/repos/hongkongkiwi/research-master/releases/latest")
134        .header("User-Agent", "research-master")
135        .send()
136        .await
137        .context("Failed to fetch latest release")?;
138
139    if !response.status().is_success() {
140        bail!(
141            "GitHub API request failed with status: {}",
142            response.status()
143        );
144    }
145
146    let json: serde_json::Value = response
147        .json()
148        .await
149        .context("Failed to parse release info")?;
150
151    let tag_name = json["tag_name"]
152        .as_str()
153        .context("Missing tag_name")?
154        .to_string();
155
156    let version = tag_name.trim_start_matches('v').to_string();
157
158    let body = json["body"].as_str().unwrap_or("").to_string();
159    let published_at = json["published_at"].as_str().unwrap_or("").to_string();
160
161    let mut assets = Vec::new();
162    if let Some(assets_array) = json["assets"].as_array() {
163        for asset in assets_array {
164            if let (Some(name), Some(download_url)) = (
165                asset["name"].as_str(),
166                asset["browser_download_url"].as_str(),
167            ) {
168                assets.push(ReleaseAsset {
169                    name: name.to_string(),
170                    download_url: download_url.to_string(),
171                });
172            }
173        }
174    }
175
176    Ok(ReleaseInfo {
177        tag_name,
178        version,
179        body,
180        published_at,
181        assets,
182    })
183}
184
185/// Get the target triple for the current platform
186pub fn get_current_target() -> &'static str {
187    // Get target from std
188    let target = std::env::consts::ARCH;
189
190    // Determine OS
191    let os = if cfg!(target_os = "linux") {
192        if cfg!(target_env = "musl") {
193            "unknown-linux-musl"
194        } else {
195            "unknown-linux-gnu"
196        }
197    } else if cfg!(target_os = "macos") {
198        "apple-darwin"
199    } else if cfg!(target_os = "windows") {
200        "pc-windows-msvc"
201    } else {
202        return "";
203    };
204
205    match target {
206        "x86_64" => {
207            if os == "apple-darwin" {
208                "x86_64-apple-darwin"
209            } else if os == "unknown-linux-musl" {
210                "x86_64-unknown-linux-musl"
211            } else if os == "unknown-linux-gnu" {
212                "x86_64-unknown-linux-gnu"
213            } else if os == "pc-windows-msvc" {
214                "x86_64-pc-windows-msvc"
215            } else {
216                ""
217            }
218        }
219        "aarch64" => {
220            if os == "apple-darwin" {
221                "aarch64-apple-darwin"
222            } else {
223                ""
224            }
225        }
226        _ => "",
227    }
228}
229
230/// Find the appropriate release asset for the current platform
231pub fn find_asset_for_platform(release: &ReleaseInfo) -> Option<&ReleaseAsset> {
232    let target = get_current_target();
233    if target.is_empty() {
234        return None;
235    }
236
237    // Look for tar.gz first (Linux/macOS), then zip (Windows)
238    let preferred_ext = if cfg!(target_os = "windows") {
239        ".zip"
240    } else {
241        ".tar.gz"
242    };
243
244    // Try to find exact match
245    if let Some(asset) = release
246        .assets
247        .iter()
248        .find(|asset| asset.name.contains(target) && asset.name.ends_with(preferred_ext))
249    {
250        return Some(asset);
251    }
252
253    // Fallback: just find any asset with the target
254    release
255        .assets
256        .iter()
257        .find(|asset| asset.name.contains(target))
258}
259
260/// Download and extract a release asset
261pub async fn download_and_extract_asset(asset: &ReleaseAsset, temp_dir: &Path) -> Result<PathBuf> {
262    let client = reqwest::Client::new();
263
264    // Download the archive
265    eprintln!("Downloading {}...", asset.name);
266    let response = client
267        .get(&asset.download_url)
268        .send()
269        .await
270        .context("Failed to download asset")?;
271
272    if !response.status().is_success() {
273        bail!("Download failed with status: {}", response.status());
274    }
275
276    let bytes = response
277        .bytes()
278        .await
279        .context("Failed to read response body")?;
280
281    // Save to temp file
282    let archive_path = temp_dir.join(&asset.name);
283    fs::write(&archive_path, &bytes).context("Failed to save archive")?;
284
285    // Extract based on file type
286    let binary_path = if asset.name.ends_with(".tar.gz") {
287        extract_tar_gz(&archive_path, temp_dir)?
288    } else if asset.name.ends_with(".zip") {
289        extract_zip(&archive_path, temp_dir)?
290    } else {
291        bail!("Unsupported archive format: {}", asset.name);
292    };
293
294    Ok(binary_path)
295}
296
297#[cfg(unix)]
298fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<PathBuf> {
299    use std::os::unix::fs::PermissionsExt;
300
301    // Use tar to extract
302    let output = Command::new("tar")
303        .args([
304            "xzf",
305            archive_path.to_str().unwrap(),
306            "-C",
307            dest_dir.to_str().unwrap(),
308        ])
309        .output()
310        .context("Failed to extract tar.gz")?;
311
312    if !output.status.success() {
313        bail!(
314            "tar extraction failed: {}",
315            String::from_utf8_lossy(&output.stderr)
316        );
317    }
318
319    // Find the extracted binary
320    for entry in fs::read_dir(dest_dir)? {
321        let entry = entry?;
322        let path = entry.path();
323        if path.is_file()
324            && path
325                .file_name()
326                .map(|n| n.to_string_lossy().starts_with("research-master"))
327                .unwrap_or(false)
328        {
329            // Make executable
330            let mut perms = fs::metadata(&path)?.permissions();
331            perms.set_mode(0o755);
332            fs::set_permissions(&path, perms)?;
333            return Ok(path);
334        }
335    }
336
337    bail!("Could not find binary in archive")
338}
339
340#[cfg(windows)]
341fn extract_tar_gz(_archive_path: &Path, _dest_dir: &Path) -> Result<PathBuf> {
342    bail!("tar.gz extraction on Windows requires additional dependencies")
343}
344
345#[cfg(windows)]
346fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<PathBuf> {
347    use zip::ZipArchive;
348
349    let file = fs::File::open(archive_path)?;
350    let mut archive = ZipArchive::new(file)?;
351
352    for i in 0..archive.len() {
353        let mut entry = archive.by_index(i)?;
354        let out_path = dest_dir.join(entry.name());
355
356        if entry.is_dir() {
357            fs::create_dir_all(&out_path)?;
358        } else {
359            if let Some(parent) = out_path.parent() {
360                fs::create_dir_all(parent)?;
361            }
362            let mut out_file = fs::File::create(&out_path)?;
363            std::io::copy(&mut entry, &mut out_file)?;
364        }
365    }
366
367    // Find the binary
368    for entry in fs::read_dir(dest_dir)? {
369        let entry = entry?;
370        let path = entry.path();
371        if path.is_file()
372            && path
373                .file_name()
374                .map(|n| n.to_string_lossy().starts_with("research-master"))
375                .unwrap_or(false)
376        {
377            return Ok(path);
378        }
379    }
380
381    bail!("Could not find binary in archive")
382}
383
384#[cfg(unix)]
385fn extract_zip(_archive_path: &Path, _dest_dir: &Path) -> Result<PathBuf> {
386    bail!("zip extraction on Unix requires additional dependencies")
387}
388
389/// Replace the current binary with a new one
390pub fn replace_binary(current: &Path, new: &Path) -> Result<()> {
391    #[cfg(unix)]
392    {
393        // On Unix, we need to copy to a temp location first, then rename
394        // because the current binary is still running
395        let temp_path = current.with_file_name(format!(
396            "{}.new",
397            current.file_name().unwrap().to_string_lossy()
398        ));
399
400        // Copy new binary to temp location
401        fs::copy(new, &temp_path)?;
402        std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755))?;
403
404        // Rename temp to current (atomic on POSIX)
405        // First, rename current to backup
406        let backup_path = current.with_file_name(format!(
407            "{}.backup",
408            current.file_name().unwrap().to_string_lossy()
409        ));
410        if current.exists() {
411            fs::rename(current, &backup_path)?;
412        }
413
414        // Rename temp to current
415        fs::rename(&temp_path, current)?;
416
417        // Remove backup
418        if backup_path.exists() {
419            fs::remove_file(&backup_path)?;
420        }
421
422        Ok(())
423    }
424
425    #[cfg(windows)]
426    {
427        // On Windows, we can't replace a running executable
428        // So we copy to a .new file and tell the user to restart
429        let new_path = current.with_extension(".exe.new");
430        fs::copy(new, &new_path)?;
431        eprintln!(
432            "New binary downloaded to: {}. Please restart your terminal to use the new version.",
433            new_path.display()
434        );
435        Ok(())
436    }
437}
438
439/// Clean up temporary files
440pub fn cleanup_temp_files(files: Vec<PathBuf>) {
441    for file in files {
442        if file.exists() {
443            let _ = fs::remove_file(file);
444        }
445    }
446}
447
448/// Fetch and verify SHA256 checksum for a file
449pub async fn fetch_and_verify_sha256(asset_name: &str, _temp_dir: &Path) -> Result<String> {
450    let client = reqwest::Client::new();
451    let checksums_url =
452        "https://github.com/hongkongkiwi/research-master/releases/download/latest/SHA256SUMS.txt";
453
454    eprintln!("Downloading SHA256 checksums...");
455    let response = client
456        .get(checksums_url)
457        .header("User-Agent", "research-master")
458        .send()
459        .await
460        .context("Failed to download checksums file")?;
461
462    if !response.status().is_success() {
463        bail!("Failed to download checksums (HTTP {})", response.status());
464    }
465
466    let checksums_text = response.text().await.context("Failed to read checksums")?;
467
468    // Parse the checksums file and find the matching entry
469    for line in checksums_text.lines() {
470        let parts: Vec<&str> = line.split_whitespace().collect();
471        if parts.len() >= 2 {
472            let hash = parts[0];
473            let filename = parts.last().unwrap_or(&"");
474
475            // Handle both formats:
476            // e.g., "abc123...  research-master-x86_64-unknown-linux-musl.tar.gz"
477            // or "abc123...  ./research-master-x86_64-unknown-linux-musl.tar.gz"
478            let normalized_filename = filename.trim_start_matches("./");
479
480            if normalized_filename == asset_name || filename.contains(asset_name) {
481                return Ok(hash.to_string());
482            }
483        }
484    }
485
486    bail!("Checksum not found for {}", asset_name)
487}
488
489/// Compute SHA256 hash of a file
490pub fn compute_sha256(file_path: &Path) -> Result<String> {
491    use sha2::{Digest, Sha256};
492
493    let data = fs::read(file_path).context("Failed to read file for checksum")?;
494    let mut hasher = Sha256::new();
495    hasher.update(&data);
496    let result = hasher.finalize();
497    Ok(format!("{:x}", result))
498}
499
500/// Verify downloaded file against expected SHA256 hash
501pub fn verify_sha256(file_path: &Path, expected_hash: &str) -> Result<bool> {
502    let actual_hash = compute_sha256(file_path)?;
503
504    if actual_hash == expected_hash {
505        Ok(true)
506    } else {
507        eprintln!("SHA256 mismatch!");
508        eprintln!("Expected: {}", expected_hash);
509        eprintln!("Actual:   {}", actual_hash);
510        Ok(false)
511    }
512}
513
514/// Fetch the GPG signature for SHA256SUMS.txt
515pub async fn fetch_sha256_signature() -> Result<String> {
516    let client = reqwest::Client::new();
517    let signature_url = "https://github.com/hongkongkiwi/research-master/releases/download/latest/SHA256SUMS.txt.asc";
518
519    eprintln!("Downloading GPG signature...");
520    let response = client
521        .get(signature_url)
522        .header("User-Agent", "research-master")
523        .send()
524        .await
525        .context("Failed to download GPG signature")?;
526
527    if !response.status().is_success() {
528        bail!(
529            "Failed to download GPG signature (HTTP {})",
530            response.status()
531        );
532    }
533
534    let signature = response.text().await.context("Failed to read signature")?;
535    Ok(signature)
536}
537
538/// Verify GPG signature of SHA256SUMS.txt
539/// This requires the project maintainer's public key to be in the system keyring.
540/// For CI/CD, set GPG_FINGERPRINT to the expected signer's fingerprint.
541pub fn verify_gpg_signature(sha256sums_path: &Path, signature: &str) -> Result<bool> {
542    use std::io::Write as _;
543
544    // Check if GPG is available first
545    if !is_command_available("gpg") {
546        #[cfg(windows)]
547        {
548            eprintln!("WARNING: GPG is not installed or not in PATH.");
549            eprintln!("On Windows, install GPG from https://www.gpg4win.org/");
550        }
551        #[cfg(not(windows))]
552        {
553            eprintln!("WARNING: GPG is not installed or not in PATH.");
554            eprintln!("Install GPG with your package manager (e.g., brew install gnupg)");
555        }
556        eprintln!("Skipping GPG signature verification.");
557        return Ok(false);
558    }
559
560    // Write signature to a temp file
561    let sig_path = sha256sums_path.with_extension("txt.asc");
562    let mut sig_file = std::fs::File::create(&sig_path)?;
563    sig_file.write_all(signature.as_bytes())?;
564    sig_file.flush()?;
565
566    // Verify using gpg
567    let output = Command::new("gpg")
568        .args([
569            "--verify",
570            sig_path.to_str().unwrap(),
571            sha256sums_path.to_str().unwrap(),
572        ])
573        .output()
574        .context("Failed to run gpg")?;
575
576    // Clean up signature file
577    let _ = std::fs::remove_file(&sig_path);
578
579    let stderr = String::from_utf8_lossy(&output.stderr);
580
581    // Check for "Good signature" in output
582    if stderr.contains("Good signature") || stderr.contains("gpg: Good signature") {
583        // Optionally verify the signer if fingerprint is set
584        if let Ok(fingerprint) = std::env::var("GPG_FINGERPRINT") {
585            if stderr.contains(&fingerprint) || output.status.success() {
586                eprintln!("GPG signature verified successfully!");
587                return Ok(true);
588            } else {
589                eprintln!("WARNING: Signature is good but from unexpected signer!");
590                eprintln!("Expected fingerprint: {}", fingerprint);
591                return Ok(false);
592            }
593        }
594        eprintln!("GPG signature verified successfully!");
595        return Ok(true);
596    }
597
598    if stderr.contains("BAD signature") || stderr.contains("gpg: BAD signature") {
599        eprintln!("ERROR: GPG signature verification FAILED!");
600        eprintln!("{}", stderr);
601        return Ok(false);
602    }
603
604    // If gpg is not available or key not found
605    if stderr.contains("no public key") || stderr.contains("gpg: Can't check signature") {
606        eprintln!("WARNING: GPG is not configured properly.");
607        eprintln!("To enable GPG verification, either:");
608        eprintln!("  1. Install GPG and import the maintainer's public key");
609        eprintln!("  2. Set GPG_FINGERPRINT to skip signer verification");
610        return Ok(false);
611    }
612
613    eprintln!("GPG verification result: {}", stderr);
614    Ok(false)
615}