1use 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
11fn 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#[derive(Debug, Clone)]
22pub enum InstallationMethod {
23 Homebrew {
25 path: PathBuf,
27 },
28 Cargo {
30 path: PathBuf,
32 },
33 Direct {
35 path: PathBuf,
37 },
38 Unknown,
40}
41
42pub 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 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 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 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
87pub 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#[derive(Debug, Clone)]
107pub struct ReleaseInfo {
108 pub tag_name: String,
110 pub version: String,
112 pub body: String,
114 pub published_at: String,
116 pub assets: Vec<ReleaseAsset>,
118}
119
120#[derive(Debug, Clone)]
122pub struct ReleaseAsset {
123 pub name: String,
125 pub download_url: String,
127}
128
129pub 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
185pub fn get_current_target() -> &'static str {
187 let target = std::env::consts::ARCH;
189
190 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
230pub 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 let preferred_ext = if cfg!(target_os = "windows") {
239 ".zip"
240 } else {
241 ".tar.gz"
242 };
243
244 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 release
255 .assets
256 .iter()
257 .find(|asset| asset.name.contains(target))
258}
259
260pub async fn download_and_extract_asset(asset: &ReleaseAsset, temp_dir: &Path) -> Result<PathBuf> {
262 let client = reqwest::Client::new();
263
264 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 let archive_path = temp_dir.join(&asset.name);
283 fs::write(&archive_path, &bytes).context("Failed to save archive")?;
284
285 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 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 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 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 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
389pub fn replace_binary(current: &Path, new: &Path) -> Result<()> {
391 #[cfg(unix)]
392 {
393 let temp_path = current.with_file_name(format!(
396 "{}.new",
397 current.file_name().unwrap().to_string_lossy()
398 ));
399
400 fs::copy(new, &temp_path)?;
402 std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o755))?;
403
404 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 fs::rename(&temp_path, current)?;
416
417 if backup_path.exists() {
419 fs::remove_file(&backup_path)?;
420 }
421
422 Ok(())
423 }
424
425 #[cfg(windows)]
426 {
427 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
439pub 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
448pub 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 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 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
489pub 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
500pub 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
514pub 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
538pub fn verify_gpg_signature(sha256sums_path: &Path, signature: &str) -> Result<bool> {
542 use std::io::Write as _;
543
544 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 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 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 let _ = std::fs::remove_file(&sig_path);
578
579 let stderr = String::from_utf8_lossy(&output.stderr);
580
581 if stderr.contains("Good signature") || stderr.contains("gpg: Good signature") {
583 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 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}