/ crates / acdc-install / src / binary.rs
binary.rs
  1  //! Binary installation from pre-built releases.
  2  
  3  use acdc_core::Result;
  4  use acdc_tui::output;
  5  use sha2::{Digest, Sha256};
  6  use std::io::Read;
  7  use std::path::Path;
  8  use tokio::io::AsyncWriteExt;
  9  use tracing::{info, warn};
 10  
 11  /// Release download base URL.
 12  const RELEASE_BASE_URL: &str = "https://releases.ac-dc.network";
 13  
 14  /// Binary component information.
 15  #[derive(Debug, Clone)]
 16  pub struct BinaryInfo {
 17      /// Component name.
 18      pub name: String,
 19      /// Download URL.
 20      pub url: String,
 21      /// Expected SHA256 checksum.
 22      pub checksum: String,
 23      /// Installation path.
 24      pub install_path: std::path::PathBuf,
 25  }
 26  
 27  /// Get binary info for a component.
 28  pub fn get_binary_info(component: &str, version: &str) -> BinaryInfo {
 29      let arch = get_arch();
 30      let os = get_os();
 31      let filename = format!("{component}-{version}-{arch}-{os}.tar.gz");
 32  
 33      BinaryInfo {
 34          name: component.to_string(),
 35          url: format!("{RELEASE_BASE_URL}/{version}/{filename}"),
 36          checksum: format!("{RELEASE_BASE_URL}/{version}/{filename}.sha256"),
 37          install_path: std::path::PathBuf::from("/usr/local/bin").join(component),
 38      }
 39  }
 40  
 41  /// Components to install.
 42  const COMPONENTS: &[&str] = &["adnet", "alphaos", "deltaos"];
 43  
 44  /// Install pre-built binaries.
 45  pub async fn install() -> Result<()> {
 46      info!("Starting binary installation...");
 47  
 48      let version = get_latest_version().await?;
 49      info!(version = %version, "Installing version");
 50  
 51      // Install all components
 52      for component in COMPONENTS {
 53          match install_component(component, &version).await {
 54              Ok(()) => {
 55                  info!(component = component, "Component installed successfully");
 56              }
 57              Err(e) => {
 58                  // Log error but continue with other components
 59                  warn!(component = component, error = %e, "Failed to install component");
 60                  output::warning(&format!("  Failed to install {}: {}", component, e));
 61              }
 62          }
 63      }
 64  
 65      output::success("Binary installation complete");
 66      Ok(())
 67  }
 68  
 69  /// Install a single component.
 70  pub async fn install_component(component: &str, version: &str) -> Result<()> {
 71      let info = get_binary_info(component, version);
 72  
 73      output::status(&format!("Installing {}", component));
 74  
 75      // Create temp directory
 76      let temp_dir = tempfile::tempdir()?;
 77      let archive_path = temp_dir.path().join(format!("{}.tar.gz", component));
 78  
 79      // Download binary
 80      output::status("  Downloading...");
 81      download_with_progress(&info.url, &archive_path).await?;
 82  
 83      // Fetch and verify checksum
 84      output::status("  Verifying checksum...");
 85      let expected_checksum = fetch_checksum(&info.checksum).await?;
 86      if !verify_checksum(&archive_path, &expected_checksum)? {
 87          return Err(acdc_core::Error::Installation(format!(
 88              "Checksum verification failed for {}",
 89              component
 90          )));
 91      }
 92      output::success("  Checksum verified");
 93  
 94      // Extract and install
 95      output::status("  Installing...");
 96      extract_and_install(&archive_path, &info.install_path).await?;
 97  
 98      output::success(&format!("  {} installed successfully", component));
 99      Ok(())
100  }
101  
102  /// Get the latest release version.
103  async fn get_latest_version() -> Result<String> {
104      let client = reqwest::Client::new();
105      let url = format!("{RELEASE_BASE_URL}/latest");
106  
107      let response = client.get(&url).send().await?;
108  
109      if !response.status().is_success() {
110          // Fall back to default version if latest endpoint not available
111          warn!("Could not fetch latest version, using default");
112          return Ok("0.2.0".to_string());
113      }
114  
115      let version = response.text().await?.trim().to_string();
116      Ok(version)
117  }
118  
119  /// Download a file with progress indication.
120  pub async fn download_with_progress(url: &str, dest: &Path) -> Result<()> {
121      let client = reqwest::Client::new();
122      let response = client.get(url).send().await?;
123  
124      if !response.status().is_success() {
125          return Err(acdc_core::Error::Network(format!(
126              "Failed to download {}: HTTP {}",
127              url,
128              response.status()
129          )));
130      }
131  
132      let total_size = response.content_length().unwrap_or(0);
133  
134      let mut file = tokio::fs::File::create(dest).await?;
135      let mut downloaded: u64 = 0;
136      let mut stream = response.bytes_stream();
137  
138      use tokio_stream::StreamExt;
139      while let Some(chunk) = stream.next().await {
140          let chunk = chunk?;
141          file.write_all(&chunk).await?;
142          downloaded += chunk.len() as u64;
143  
144          if total_size > 0 {
145              let percent = (downloaded as f64 / total_size as f64 * 100.0) as u8;
146              // Progress indication would go here in TUI mode
147              let _ = percent; // Suppress unused warning
148          }
149      }
150  
151      file.flush().await?;
152      Ok(())
153  }
154  
155  /// Fetch checksum from URL.
156  async fn fetch_checksum(url: &str) -> Result<String> {
157      let client = reqwest::Client::new();
158      let response = client.get(url).send().await?;
159  
160      if !response.status().is_success() {
161          return Err(acdc_core::Error::Network(format!(
162              "Failed to fetch checksum from {}: HTTP {}",
163              url,
164              response.status()
165          )));
166      }
167  
168      // Checksum file format: "HASH  filename" or just "HASH"
169      let text = response.text().await?;
170      let checksum = text
171          .split_whitespace()
172          .next()
173          .ok_or_else(|| acdc_core::Error::Installation("Invalid checksum format".to_string()))?
174          .to_string();
175  
176      Ok(checksum)
177  }
178  
179  /// Verify SHA256 checksum of a file.
180  pub fn verify_checksum(path: &Path, expected: &str) -> Result<bool> {
181      let mut file = std::fs::File::open(path)?;
182      let mut hasher = Sha256::new();
183      let mut buffer = [0u8; 8192];
184  
185      loop {
186          let bytes_read = file.read(&mut buffer)?;
187          if bytes_read == 0 {
188              break;
189          }
190          hasher.update(&buffer[..bytes_read]);
191      }
192  
193      let result = hasher.finalize();
194      let actual = hex::encode(result);
195  
196      Ok(actual.eq_ignore_ascii_case(expected))
197  }
198  
199  /// Extract archive and install binary.
200  async fn extract_and_install(archive: &Path, install_path: &Path) -> Result<()> {
201      use flate2::read::GzDecoder;
202      use std::process::Command;
203      use tar::Archive;
204  
205      let temp_dir = tempfile::tempdir()?;
206  
207      // Extract archive
208      let file = std::fs::File::open(archive)?;
209      let decoder = GzDecoder::new(file);
210      let mut archive = Archive::new(decoder);
211      archive.unpack(temp_dir.path())?;
212  
213      // Find the binary in extracted contents
214      let binary_name = install_path
215          .file_name()
216          .and_then(|n| n.to_str())
217          .unwrap_or("binary");
218  
219      let extracted_binary = find_binary_in_dir(temp_dir.path(), binary_name)?;
220  
221      // Install binary (may need sudo)
222      let install_dir = install_path.parent().unwrap_or(Path::new("/usr/local/bin"));
223  
224      // Try direct copy first
225      if std::fs::copy(&extracted_binary, install_path).is_err() {
226          // Fall back to sudo
227          let status = Command::new("sudo")
228              .args([
229                  "install",
230                  "-m",
231                  "755",
232                  extracted_binary.to_str().unwrap(),
233                  install_path.to_str().unwrap(),
234              ])
235              .status()?;
236  
237          if !status.success() {
238              return Err(acdc_core::Error::Installation(format!(
239                  "Failed to install binary to {}",
240                  install_dir.display()
241              )));
242          }
243      }
244  
245      // Make executable
246      #[cfg(unix)]
247      {
248          use std::os::unix::fs::PermissionsExt;
249          if let Ok(mut perms) = std::fs::metadata(install_path).map(|m| m.permissions()) {
250              perms.set_mode(0o755);
251              let _ = std::fs::set_permissions(install_path, perms);
252          }
253      }
254  
255      Ok(())
256  }
257  
258  /// Find a binary in a directory (possibly nested).
259  fn find_binary_in_dir(dir: &Path, name: &str) -> Result<std::path::PathBuf> {
260      for entry in walkdir::WalkDir::new(dir).max_depth(3) {
261          let entry = match entry {
262              Ok(e) => e,
263              Err(_) => continue,
264          };
265          if entry.file_type().is_file() {
266              if let Some(file_name) = entry.file_name().to_str() {
267                  if file_name == name || file_name.starts_with(name) {
268                      return Ok(entry.path().to_path_buf());
269                  }
270              }
271          }
272      }
273  
274      Err(acdc_core::Error::Installation(format!(
275          "Binary '{}' not found in archive",
276          name
277      )))
278  }
279  
280  /// Get current architecture string.
281  fn get_arch() -> &'static str {
282      #[cfg(target_arch = "x86_64")]
283      return "x86_64";
284  
285      #[cfg(target_arch = "aarch64")]
286      return "aarch64";
287  
288      #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
289      return "unknown";
290  }
291  
292  /// Get current OS string.
293  fn get_os() -> &'static str {
294      #[cfg(target_os = "linux")]
295      return "linux";
296  
297      #[cfg(target_os = "macos")]
298      return "darwin";
299  
300      #[cfg(not(any(target_os = "linux", target_os = "macos")))]
301      return "unknown";
302  }