Skip to main content

coding_agent_search/sources/
install.rs

1//! Remote cass installation via SSH.
2//!
3//! This module provides functionality to automatically install cass on remote
4//! machines via SSH. It supports multiple installation methods with intelligent
5//! fallback and robust handling of long-running installations.
6//!
7//! # Installation Methods (Priority Order)
8//!
9//! 1. **Cargo Binstall** (fastest if available) - downloads pre-built binary via
10//!    cargo, may fall back to a source build
11//! 2. **Pre-built Binary** - direct binary download from GitHub releases with checksum verification
12//! 3. **Cargo Install** - compile from source (most reliable fallback)
13//! 4. **Full Bootstrap** - install rustup first, then compile
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use coding_agent_search::sources::install::{RemoteInstaller, InstallProgress};
19//! use coding_agent_search::sources::probe::{SystemInfo, ResourceInfo};
20//!
21//! let installer = RemoteInstaller::new("laptop", system_info, resources);
22//!
23//! installer.install(|progress| {
24//!     println!("{}: {}", progress.stage, progress.message);
25//! })?;
26//! ```
27
28use std::io::Write as IoWrite;
29use std::process::{Command, Stdio};
30use std::time::{Duration, Instant};
31
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34
35use super::{
36    host_key_verification_error, is_host_key_verification_failure,
37    probe::{ResourceInfo, SystemInfo},
38    strict_ssh_cli_tokens, wait_for_child_output_with_timeout,
39};
40
41// =============================================================================
42// Constants
43// =============================================================================
44
45/// Default SSH connection timeout for installation commands.
46pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 600; // 10 minutes for cargo install
47
48/// Minimum disk space required for installation (MB).
49pub const MIN_DISK_MB: u64 = ResourceInfo::MIN_DISK_MB;
50
51/// Minimum memory recommended for compilation (MB).
52pub const MIN_MEMORY_MB: u64 = ResourceInfo::MIN_MEMORY_MB;
53
54/// Current cass version for installation.
55pub const CASS_VERSION: &str = env!("CARGO_PKG_VERSION");
56
57/// Package name on crates.io.
58pub const CRATE_NAME: &str = "coding-agent-search";
59
60// =============================================================================
61// Error Types
62// =============================================================================
63
64/// Errors that can occur during remote installation.
65#[derive(Error, Debug)]
66pub enum InstallError {
67    #[error("SSH connection failed: {0}")]
68    SshFailed(String),
69
70    #[error("SSH connection timed out after {0} seconds")]
71    Timeout(u64),
72
73    #[error("Insufficient disk space: {available_mb}MB available, {required_mb}MB required")]
74    InsufficientDisk { available_mb: u64, required_mb: u64 },
75
76    #[error("Insufficient memory: {available_mb}MB available, {required_mb}MB recommended")]
77    InsufficientMemory { available_mb: u64, required_mb: u64 },
78
79    #[error("Installation method {method} failed: {reason}")]
80    MethodFailed { method: String, reason: String },
81
82    #[error("No suitable installation method available")]
83    NoMethodAvailable,
84
85    #[error("Verification failed: {0}")]
86    VerificationFailed(String),
87
88    #[error("Checksum mismatch: expected {expected}, got {actual}")]
89    ChecksumMismatch { expected: String, actual: String },
90
91    #[error("Missing system dependency: {dep}. Fix: {fix}")]
92    MissingDependency { dep: String, fix: String },
93
94    #[error("Installation cancelled")]
95    Cancelled,
96
97    #[error("IO error: {0}")]
98    Io(#[from] std::io::Error),
99}
100
101// =============================================================================
102// Install Method Types
103// =============================================================================
104
105/// Installation method for cass.
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(tag = "method", rename_all = "snake_case")]
108pub enum InstallMethod {
109    /// Install via cargo-binstall (fastest, may fall back to a source build).
110    CargoBinstall,
111
112    /// Download pre-built binary directly from GitHub releases.
113    PrebuiltBinary {
114        url: String,
115        checksum: Option<String>,
116    },
117
118    /// Compile from source via cargo install.
119    CargoInstall,
120
121    /// Full bootstrap: install rustup first, then compile.
122    FullBootstrap,
123}
124
125impl InstallMethod {
126    /// Get display name for the method.
127    pub fn display_name(&self) -> &'static str {
128        match self {
129            InstallMethod::CargoBinstall => "cargo-binstall",
130            InstallMethod::PrebuiltBinary { .. } => "pre-built binary",
131            InstallMethod::CargoInstall => "cargo install",
132            InstallMethod::FullBootstrap => "full bootstrap (rustup + cargo)",
133        }
134    }
135
136    /// Estimated time for this method.
137    pub fn estimated_time(&self) -> Duration {
138        match self {
139            InstallMethod::CargoBinstall => Duration::from_secs(30),
140            InstallMethod::PrebuiltBinary { .. } => Duration::from_secs(10),
141            InstallMethod::CargoInstall => Duration::from_secs(300), // 5 minutes
142            InstallMethod::FullBootstrap => Duration::from_secs(600), // 10 minutes
143        }
144    }
145
146    /// Whether this method requires compile-safe resources before cass attempts it.
147    pub fn requires_compilation(&self) -> bool {
148        matches!(
149            self,
150            InstallMethod::CargoBinstall
151                | InstallMethod::CargoInstall
152                | InstallMethod::FullBootstrap
153        )
154    }
155}
156
157impl std::fmt::Display for InstallMethod {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        write!(f, "{}", self.display_name())
160    }
161}
162
163// =============================================================================
164// Progress Types
165// =============================================================================
166
167/// Current stage of installation.
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum InstallStage {
171    /// Preparing installation (checking resources, selecting method).
172    Preparing,
173    /// Downloading files.
174    Downloading,
175    /// Compiling code.
176    Compiling { crate_name: String },
177    /// Installing binary.
178    Installing,
179    /// Verifying installation.
180    Verifying,
181    /// Installation complete.
182    Complete,
183    /// Installation failed.
184    Failed { error: String },
185}
186
187impl std::fmt::Display for InstallStage {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            InstallStage::Preparing => write!(f, "Preparing"),
191            InstallStage::Downloading => write!(f, "Downloading"),
192            InstallStage::Compiling { crate_name } => write!(f, "Compiling {}", crate_name),
193            InstallStage::Installing => write!(f, "Installing"),
194            InstallStage::Verifying => write!(f, "Verifying"),
195            InstallStage::Complete => write!(f, "Complete"),
196            InstallStage::Failed { error } => write!(f, "Failed: {}", error),
197        }
198    }
199}
200
201/// Progress update during installation.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct InstallProgress {
204    /// Current stage.
205    pub stage: InstallStage,
206    /// Human-readable message.
207    pub message: String,
208    /// Optional progress percentage (0-100).
209    pub percent: Option<u8>,
210    /// Elapsed time since start.
211    pub elapsed: Duration,
212}
213
214/// Result of a successful installation.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct InstallResult {
217    /// Method used for installation.
218    pub method: InstallMethod,
219    /// Installed version.
220    pub version: String,
221    /// Total installation time.
222    pub duration: Duration,
223    /// Installation path.
224    pub install_path: Option<String>,
225}
226
227fn install_poll_status(output: &str) -> Option<&str> {
228    output
229        .lines()
230        .filter_map(|line| line.trim().strip_prefix("STATUS="))
231        .next_back()
232}
233
234fn output_has_exact_line(output: &str, needle: &str) -> bool {
235    output.lines().any(|line| line.trim() == needle)
236}
237
238fn first_version_components(text: &str) -> Option<(u64, u64)> {
239    let start = text.find(|ch: char| ch.is_ascii_digit())?;
240    let version_tail = &text[start..];
241    let major_end = version_tail
242        .find(|ch: char| !ch.is_ascii_digit())
243        .unwrap_or(version_tail.len());
244    let major = version_tail[..major_end].parse::<u64>().ok()?;
245    let rest = &version_tail[major_end..];
246    let minor = rest
247        .strip_prefix('.')
248        .and_then(|after_dot| {
249            let minor_end = after_dot
250                .find(|ch: char| !ch.is_ascii_digit())
251                .unwrap_or(after_dot.len());
252            after_dot.get(..minor_end)
253        })
254        .filter(|value| !value.is_empty())
255        .and_then(|value| value.parse::<u64>().ok())
256        .unwrap_or(0);
257
258    Some((major, minor))
259}
260
261// =============================================================================
262// RemoteInstaller
263// =============================================================================
264
265/// Installer for cass on remote machines.
266pub struct RemoteInstaller {
267    /// SSH host alias.
268    host: String,
269    /// System information from probe.
270    system_info: SystemInfo,
271    /// Resource information from probe.
272    resources: ResourceInfo,
273    /// Target version to install.
274    target_version: String,
275}
276
277impl RemoteInstaller {
278    /// Create a new installer for a remote host.
279    pub fn new(host: impl Into<String>, system_info: SystemInfo, resources: ResourceInfo) -> Self {
280        Self {
281            host: host.into(),
282            system_info,
283            resources,
284            target_version: CASS_VERSION.to_string(),
285        }
286    }
287
288    /// Create an installer with a specific target version.
289    ///
290    /// Returns an error if the version string contains characters that are not
291    /// safe for shell interpolation (only alphanumeric, `.`, `-`, `+`, `_` allowed).
292    pub fn with_version(
293        host: impl Into<String>,
294        system_info: SystemInfo,
295        resources: ResourceInfo,
296        version: impl Into<String>,
297    ) -> Result<Self, InstallError> {
298        let version = version.into();
299        Self::validate_shell_safe(&version, "version")?;
300        Ok(Self {
301            host: host.into(),
302            system_info,
303            resources,
304            target_version: version,
305        })
306    }
307
308    /// Validate that a string is safe for shell interpolation.
309    ///
310    /// Prevents command injection by rejecting strings containing shell
311    /// metacharacters (quotes, backticks, semicolons, pipes, etc.).
312    fn validate_shell_safe(value: &str, field_name: &str) -> Result<(), InstallError> {
313        if value.is_empty() {
314            return Err(InstallError::VerificationFailed(format!(
315                "{field_name} must not be empty"
316            )));
317        }
318        if !value
319            .chars()
320            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'))
321        {
322            return Err(InstallError::VerificationFailed(format!(
323                "{field_name} contains unsafe characters: only alphanumeric, '.', '-', '+', '_' are allowed"
324            )));
325        }
326        Ok(())
327    }
328
329    /// Get the host name.
330    pub fn host(&self) -> &str {
331        &self.host
332    }
333
334    /// Get the target version.
335    pub fn target_version(&self) -> &str {
336        &self.target_version
337    }
338
339    /// Check if resources are sufficient for compilation.
340    pub fn check_resources(&self) -> Result<(), InstallError> {
341        if self.resources.disk_available_mb < MIN_DISK_MB {
342            return Err(InstallError::InsufficientDisk {
343                available_mb: self.resources.disk_available_mb,
344                required_mb: MIN_DISK_MB,
345            });
346        }
347        // Only check memory if compilation is needed
348        // Note: we check during method selection
349        Ok(())
350    }
351
352    /// Check if resources are sufficient for compilation specifically.
353    pub fn can_compile(&self) -> Result<(), InstallError> {
354        self.check_resources()?;
355        if self.resources.memory_total_mb < MIN_MEMORY_MB {
356            return Err(InstallError::InsufficientMemory {
357                available_mb: self.resources.memory_total_mb,
358                required_mb: MIN_MEMORY_MB,
359            });
360        }
361        Ok(())
362    }
363
364    /// Choose the best installation method based on system info.
365    ///
366    /// Returns `None` if no viable installation method is available.
367    pub fn choose_method(&self) -> Option<InstallMethod> {
368        // 1. Try cargo-binstall first when source fallback is safe and the
369        // binary fast path is compatible with the remote. On known old glibc
370        // Linux distros, binstall can fetch the same incompatible release
371        // asset that direct prebuilt installs use, so prefer source there.
372        if self.system_info.has_cargo_binstall
373            && self.can_compile().is_ok()
374            && self.prebuilt_binary_fast_path_is_safe()
375        {
376            return Some(InstallMethod::CargoBinstall);
377        }
378
379        // 2. Try pre-built binary if available, compatible, and backed by
380        // release checksum evidence. Remote installs should fail closed rather
381        // than copying an unverified binary onto a user's machine.
382        if let Some(url) = self.get_prebuilt_url() {
383            let checksum = self.fetch_remote_prebuilt_checksum(&url);
384            if let Some(method) = Self::verified_prebuilt_binary_method(url, checksum) {
385                return Some(method);
386            }
387        }
388
389        // 3. Try cargo install if cargo is available and we have resources
390        if self.system_info.has_cargo && self.can_compile().is_ok() {
391            return Some(InstallMethod::CargoInstall);
392        }
393
394        // 4. Full bootstrap installs rustup and then compiles from source, so
395        // it needs the same compile resources as cargo install. Check before
396        // mutating the remote with a new toolchain.
397        if self.system_info.has_curl && self.can_compile().is_ok() {
398            return Some(InstallMethod::FullBootstrap);
399        }
400
401        // No viable method available
402        None
403    }
404
405    fn prebuilt_binary_fast_path_is_safe(&self) -> bool {
406        if self.system_info.os.to_lowercase() != "linux" {
407            return true;
408        }
409        Self::linux_prebuilt_binary_supported_by_distro(self.system_info.distro.as_deref())
410    }
411
412    /// Get pre-built binary URL if available for this architecture.
413    fn get_prebuilt_url(&self) -> Option<String> {
414        // Only supported if we have a way to download
415        if !self.system_info.has_curl && !self.system_info.has_wget {
416            return None;
417        }
418
419        // Map arch to release asset naming
420        let arch = match self.system_info.arch.as_str() {
421            "x86_64" => "amd64",
422            "aarch64" | "arm64" => "arm64",
423            _ => return None, // Unsupported arch
424        };
425
426        let os = match self.system_info.os.to_lowercase().as_str() {
427            "linux" => "linux",
428            "darwin" => "darwin",
429            _ => return None, // Unsupported OS
430        };
431        if os == "linux" && !self.prebuilt_binary_fast_path_is_safe() {
432            return None;
433        }
434
435        // macOS Intel builds are not published (see release workflow comment).
436        if os == "darwin" && arch == "amd64" {
437            return None;
438        }
439
440        // GitHub releases URL pattern
441        Some(format!(
442            "https://github.com/Dicklesworthstone/coding_agent_session_search/releases/download/v{}/cass-{}-{}.tar.gz",
443            self.target_version, os, arch
444        ))
445    }
446
447    fn verified_prebuilt_binary_method(
448        url: String,
449        checksum: Option<String>,
450    ) -> Option<InstallMethod> {
451        checksum.map(|checksum| InstallMethod::PrebuiltBinary {
452            url,
453            checksum: Some(checksum),
454        })
455    }
456
457    fn linux_prebuilt_binary_supported_by_distro(distro: Option<&str>) -> bool {
458        let Some(raw_distro) = distro else {
459            return true;
460        };
461        let distro = raw_distro.to_ascii_lowercase();
462
463        if distro.contains("alpine") || distro.contains("void linux") || distro.contains("nixos") {
464            return false;
465        }
466        if distro.contains("ubuntu") || distro.contains("pop!_os") || distro.contains("pop os") {
467            return first_version_components(&distro).is_none_or(|version| version >= (24, 4));
468        }
469        if distro.contains("linux mint") {
470            return first_version_components(&distro).is_none_or(|version| version.0 >= 22);
471        }
472        if distro.contains("elementary os") {
473            return first_version_components(&distro).is_none_or(|version| version.0 >= 8);
474        }
475        if distro.contains("zorin os") {
476            return first_version_components(&distro).is_none_or(|version| version.0 >= 18);
477        }
478        if distro.contains("debian") {
479            return first_version_components(&distro).is_none_or(|version| version.0 >= 13);
480        }
481        if distro.contains("fedora") {
482            return first_version_components(&distro).is_none_or(|version| version.0 >= 39);
483        }
484        if distro.contains("amazon linux") {
485            return false;
486        }
487        if distro.contains("centos")
488            || distro.contains("red hat")
489            || distro.contains("rhel")
490            || distro.contains("rocky")
491            || distro.contains("alma")
492            || distro.contains("oracle linux")
493        {
494            return first_version_components(&distro).is_none_or(|version| version.0 >= 10);
495        }
496
497        true
498    }
499
500    /// Get checksum URL for a pre-built binary (binary_url + ".sha256").
501    fn get_checksum_url(binary_url: &str) -> String {
502        format!("{}.sha256", binary_url)
503    }
504
505    fn prebuilt_asset_name(binary_url: &str) -> Option<String> {
506        let path = binary_url.split(['?', '#']).next().unwrap_or(binary_url);
507        let (_, name) = path.rsplit_once('/')?;
508        if name.is_empty() {
509            None
510        } else {
511            Some(name.to_string())
512        }
513    }
514
515    fn sibling_url(binary_url: &str, sibling_name: &str) -> Option<String> {
516        let base = binary_url.split(['?', '#']).next().unwrap_or(binary_url);
517        let (dir, _) = base.rsplit_once('/')?;
518        Some(format!("{dir}/{sibling_name}"))
519    }
520
521    fn checksum_urls_for_prebuilt(binary_url: &str) -> Vec<String> {
522        let mut urls = vec![Self::get_checksum_url(binary_url)];
523        if let Some(url) = Self::sibling_url(binary_url, "SHA256SUMS.txt") {
524            urls.push(url);
525        }
526        if let Some(url) = Self::sibling_url(binary_url, "SHA256SUMS") {
527            urls.push(url);
528        }
529        urls
530    }
531
532    fn checksum_url_is_aggregate(checksum_url: &str) -> bool {
533        Self::prebuilt_asset_name(checksum_url)
534            .is_some_and(|name| matches!(name.as_str(), "SHA256SUMS.txt" | "SHA256SUMS"))
535    }
536
537    fn shell_quote_arg(value: &str) -> String {
538        format!("'{}'", value.replace('\'', r#"'\''"#))
539    }
540
541    fn normalize_sha256_token(token: &str) -> Option<String> {
542        if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) {
543            Some(token.to_lowercase())
544        } else {
545            None
546        }
547    }
548
549    fn parse_remote_checksum_output(output: &str, expected_asset: Option<&str>) -> Option<String> {
550        for line in output.lines() {
551            let mut fields = line.split_whitespace();
552            let Some(candidate) = fields.next() else {
553                continue;
554            };
555            let Some(checksum) = Self::normalize_sha256_token(candidate) else {
556                continue;
557            };
558
559            let Some(expected_asset) = expected_asset else {
560                return Some(checksum);
561            };
562            let Some(asset_name) = fields.next() else {
563                continue;
564            };
565            if asset_name.trim_start_matches('*') == expected_asset {
566                return Some(checksum);
567            }
568        }
569
570        None
571    }
572
573    fn fetch_remote_prebuilt_checksum(&self, binary_url: &str) -> Option<String> {
574        let asset_name = Self::prebuilt_asset_name(binary_url)?;
575        for checksum_url in Self::checksum_urls_for_prebuilt(binary_url) {
576            let expected_asset = if Self::checksum_url_is_aggregate(&checksum_url) {
577                Some(asset_name.as_str())
578            } else {
579                None
580            };
581            if let Some(checksum) = self.fetch_remote_checksum(&checksum_url, expected_asset) {
582                return Some(checksum);
583            }
584        }
585
586        None
587    }
588
589    /// Fetch checksum from remote URL via SSH.
590    ///
591    /// Returns the SHA256 hex string if successful. If checksum resolution
592    /// fails, the direct prebuilt path is skipped so the installer can fall
593    /// back to a source-based method or fail before mutating the remote host.
594    fn fetch_remote_checksum(
595        &self,
596        checksum_url: &str,
597        expected_asset: Option<&str>,
598    ) -> Option<String> {
599        // Use curl or wget to fetch the checksum file
600        let checksum_url_arg = Self::shell_quote_arg(checksum_url);
601        let fetch_cmd = if self.system_info.has_curl {
602            format!("curl -fsSL {checksum_url_arg} 2>/dev/null")
603        } else if self.system_info.has_wget {
604            format!("wget -qO- {checksum_url_arg} 2>/dev/null")
605        } else {
606            return None;
607        };
608
609        match self.run_ssh_command(&fetch_cmd, Duration::from_secs(10)) {
610            Ok(output) => Self::parse_remote_checksum_output(&output, expected_asset),
611            Err(_) => None,
612        }
613    }
614
615    /// Install cass on the remote host.
616    ///
617    /// Streams progress updates via the callback as installation proceeds.
618    pub fn install<F>(&self, on_progress: F) -> Result<InstallResult, InstallError>
619    where
620        F: Fn(InstallProgress) + Send + Sync,
621    {
622        let start = Instant::now();
623
624        // Check resources
625        on_progress(InstallProgress {
626            stage: InstallStage::Preparing,
627            message: "Checking system resources...".into(),
628            percent: Some(0),
629            elapsed: start.elapsed(),
630        });
631
632        self.check_resources()?;
633
634        // Choose method
635        let method = self
636            .choose_method()
637            .ok_or(InstallError::NoMethodAvailable)?;
638
639        on_progress(InstallProgress {
640            stage: InstallStage::Preparing,
641            message: format!("Selected installation method: {}", method),
642            percent: Some(5),
643            elapsed: start.elapsed(),
644        });
645
646        // Execute installation
647        let result = match &method {
648            InstallMethod::CargoBinstall => self.install_via_binstall(&on_progress, start),
649            InstallMethod::PrebuiltBinary { url, checksum } => {
650                self.install_via_binary(url, checksum.as_deref(), &on_progress, start)
651            }
652            InstallMethod::CargoInstall => self.install_via_cargo(&on_progress, start),
653            InstallMethod::FullBootstrap => self.install_with_bootstrap(&on_progress, start),
654        };
655
656        match result {
657            Ok(install_result) => {
658                on_progress(InstallProgress {
659                    stage: InstallStage::Complete,
660                    message: format!(
661                        "Installed cass {} via {} in {:.1}s",
662                        install_result.version,
663                        method,
664                        install_result.duration.as_secs_f64()
665                    ),
666                    percent: Some(100),
667                    elapsed: start.elapsed(),
668                });
669                Ok(install_result)
670            }
671            Err(e) => {
672                on_progress(InstallProgress {
673                    stage: InstallStage::Failed {
674                        error: e.to_string(),
675                    },
676                    message: format!("Installation failed: {}", e),
677                    percent: None,
678                    elapsed: start.elapsed(),
679                });
680                Err(e)
681            }
682        }
683    }
684
685    /// Install via cargo-binstall.
686    fn install_via_binstall<F>(
687        &self,
688        on_progress: &F,
689        start: Instant,
690    ) -> Result<InstallResult, InstallError>
691    where
692        F: Fn(InstallProgress),
693    {
694        self.can_compile()?;
695
696        on_progress(InstallProgress {
697            stage: InstallStage::Downloading,
698            message: "Running cargo binstall...".into(),
699            percent: Some(10),
700            elapsed: start.elapsed(),
701        });
702
703        let script = format!(
704            r#"cargo binstall --no-confirm {}@{}"#,
705            CRATE_NAME, self.target_version
706        );
707
708        self.run_ssh_command(&script, Duration::from_secs(120))?;
709
710        // Verify installation
711        self.verify_installation(on_progress, start)?;
712
713        Ok(InstallResult {
714            method: InstallMethod::CargoBinstall,
715            version: self.target_version.clone(),
716            duration: start.elapsed(),
717            install_path: Some("~/.cargo/bin/cass".into()),
718        })
719    }
720
721    /// Install via pre-built binary download.
722    fn install_via_binary<F>(
723        &self,
724        url: &str,
725        checksum: Option<&str>,
726        on_progress: &F,
727        start: Instant,
728    ) -> Result<InstallResult, InstallError>
729    where
730        F: Fn(InstallProgress),
731    {
732        let checksum = checksum.ok_or_else(|| {
733            InstallError::VerificationFailed(
734                "pre-built binary checksum unavailable; refusing unverified remote install".into(),
735            )
736        })?;
737
738        on_progress(InstallProgress {
739            stage: InstallStage::Downloading,
740            message: "Downloading pre-built binary...".into(),
741            percent: Some(10),
742            elapsed: start.elapsed(),
743        });
744
745        let download_cmd =
746            Self::build_prebuilt_binary_install_script(url, checksum, self.system_info.has_curl);
747
748        self.run_ssh_command(&download_cmd, Duration::from_secs(60))?;
749
750        // Checksum is verified inside the shell script before installation.
751        let verified_checksum = checksum.to_string();
752
753        on_progress(InstallProgress {
754            stage: InstallStage::Installing,
755            message: "Binary installed and verified at ~/.local/bin/cass".into(),
756            percent: Some(80),
757            elapsed: start.elapsed(),
758        });
759
760        // Verify installation
761        self.verify_installation(on_progress, start)?;
762
763        Ok(InstallResult {
764            method: InstallMethod::PrebuiltBinary {
765                url: url.to_string(),
766                checksum: Some(verified_checksum),
767            },
768            version: self.target_version.clone(),
769            duration: start.elapsed(),
770            install_path: Some("~/.local/bin/cass".into()),
771        })
772    }
773
774    #[cfg(test)]
775    fn prebuilt_archive_member_is_allowed(member: &str) -> bool {
776        matches!(member, "cass" | "./cass")
777    }
778
779    fn build_prebuilt_binary_install_script(url: &str, checksum: &str, has_curl: bool) -> String {
780        // Download into a secure mktemp directory (not predictable /tmp/), verify
781        // checksum BEFORE extracting/installing, validate the archive layout, and
782        // clean up temp files on exit.
783        let url_arg = Self::shell_quote_arg(url);
784        let download_tool = if has_curl {
785            format!(r#"curl -fsSL {url_arg} -o "${{archive_path}}""#)
786        } else {
787            format!(r#"wget -q {url_arg} -O "${{archive_path}}""#)
788        };
789        let expected_lower = checksum.to_lowercase();
790        let expected_arg = Self::shell_quote_arg(&expected_lower);
791        let checksum_verify = format!(
792            r#"
793expected_sum={expected_arg}
794if command -v sha256sum >/dev/null 2>&1; then
795    actual_sum="$(sha256sum "${{archive_path}}" | cut -d' ' -f1)"
796elif command -v shasum >/dev/null 2>&1; then
797    actual_sum="$(shasum -a 256 "${{archive_path}}" | cut -d' ' -f1)"
798else
799    echo "CHECKSUM_TOOL_MISSING: no sha256sum or shasum found"
800    exit 1
801fi
802if [ "${{actual_sum}}" != "${{expected_sum}}" ]; then
803    echo "CHECKSUM_MISMATCH: expected ${{expected_sum}} got ${{actual_sum}}"
804    exit 1
805fi
806"#
807        );
808        format!(
809            r#"
810set -euo pipefail
811tmp_dir="$(mktemp -d)"
812trap 'rm -rf "$tmp_dir"' EXIT
813archive_path="${{tmp_dir}}/cass-prebuilt.tar.gz"
814mkdir -p ~/.local/bin
815{download_tool}
816{checksum_verify}
817tar -tzf "${{archive_path}}" | while IFS= read -r tar_member; do
818    case "${{tar_member}}" in
819        cass|./cass) ;;
820        *)
821            echo "EXTRACT_UNSAFE: ${{tar_member}}"
822            exit 1
823            ;;
824    esac
825done
826tar -xzf "${{archive_path}}" -C "${{tmp_dir}}" cass 2>/dev/null || tar -xzf "${{archive_path}}" -C "${{tmp_dir}}" ./cass
827if [ ! -f "${{tmp_dir}}/cass" ] || [ -L "${{tmp_dir}}/cass" ]; then
828    echo "EXTRACT_FAILED"
829    exit 1
830fi
831install -m 0755 "${{tmp_dir}}/cass" ~/.local/bin/cass
832# Add to PATH only if not already present
833grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
834"#
835        )
836    }
837
838    /// Compute SHA256 checksum of a file on the remote host.
839    #[allow(dead_code)] // Kept as utility; inline verification in install script is preferred
840    fn compute_remote_checksum(&self, remote_path: &str) -> Result<String, InstallError> {
841        // Try sha256sum (Linux) first, fall back to shasum -a 256 (macOS)
842        let remote_path_arg = Self::shell_quote_arg(remote_path);
843        let checksum_cmd = format!(
844            r#"
845if command -v sha256sum &>/dev/null; then
846    sha256sum {remote_path_arg} 2>/dev/null | cut -d' ' -f1
847elif command -v shasum &>/dev/null; then
848    shasum -a 256 {remote_path_arg} 2>/dev/null | cut -d' ' -f1
849else
850    echo "NO_CHECKSUM_TOOL"
851fi
852"#
853        );
854
855        let output = self.run_ssh_command(&checksum_cmd, Duration::from_secs(30))?;
856        let checksum = output.trim();
857
858        if checksum == "NO_CHECKSUM_TOOL" {
859            return Err(InstallError::MissingDependency {
860                dep: "sha256sum or shasum".into(),
861                fix: "Install coreutils (Linux) or use macOS with built-in shasum".into(),
862            });
863        }
864
865        // Validate it looks like a SHA256 hex string
866        if checksum.len() == 64 && checksum.chars().all(|c| c.is_ascii_hexdigit()) {
867            Ok(checksum.to_lowercase())
868        } else {
869            Err(InstallError::VerificationFailed(format!(
870                "Invalid checksum output: {}",
871                checksum
872            )))
873        }
874    }
875
876    /// Install via cargo install (compilation).
877    fn install_via_cargo<F>(
878        &self,
879        on_progress: &F,
880        start: Instant,
881    ) -> Result<InstallResult, InstallError>
882    where
883        F: Fn(InstallProgress),
884    {
885        // Check compilation resources
886        self.can_compile()?;
887
888        on_progress(InstallProgress {
889            stage: InstallStage::Compiling {
890                crate_name: CRATE_NAME.into(),
891            },
892            message: "Starting cargo install (this may take 2-5 minutes)...".into(),
893            percent: Some(10),
894            elapsed: start.elapsed(),
895        });
896
897        // Use nohup for long-running installation to prevent SSH timeout.
898        let install_script = self.build_cargo_install_script();
899
900        // Start the installation
901        let output = self.run_ssh_command(&install_script, Duration::from_secs(30))?;
902
903        // Extract PID for monitoring
904        let pid = output
905            .lines()
906            .find(|l| l.starts_with("INSTALL_PID="))
907            .and_then(|l| l.strip_prefix("INSTALL_PID="))
908            .and_then(|p| p.trim().parse::<u32>().ok());
909
910        // Poll for completion
911        self.poll_installation(pid, on_progress, start)?;
912
913        // Verify installation
914        self.verify_installation(on_progress, start)?;
915
916        Ok(InstallResult {
917            method: InstallMethod::CargoInstall,
918            version: self.target_version.clone(),
919            duration: start.elapsed(),
920            install_path: Some("~/.cargo/bin/cass".into()),
921        })
922    }
923
924    fn build_cargo_install_script(&self) -> String {
925        format!(
926            r#"
927# Start installation in background with logging
928LOG_FILE=~/.cass_install.log
929rm -f "$LOG_FILE"
930
931nohup bash -c '
932# Source cargo env in case this is called after bootstrap rustup install
933set -o pipefail
934source "$HOME/.cargo/env" 2>/dev/null || true
935cargo install {}@{} 2>&1 | tee "$HOME/.cass_install.log"
936status=${{PIPESTATUS[0]}}
937if [ "$status" -eq 0 ]; then
938    echo "===INSTALL_COMPLETE===" >> "$HOME/.cass_install.log"
939else
940    echo "===INSTALL_FAILED:${{status}}===" >> "$HOME/.cass_install.log"
941fi
942exit "$status"
943' > /dev/null 2>&1 &
944
945echo "INSTALL_PID=$!"
946"#,
947            CRATE_NAME, self.target_version
948        )
949    }
950
951    /// Install with full bootstrap (rustup + cargo).
952    fn install_with_bootstrap<F>(
953        &self,
954        on_progress: &F,
955        start: Instant,
956    ) -> Result<InstallResult, InstallError>
957    where
958        F: Fn(InstallProgress),
959    {
960        self.can_compile()?;
961
962        on_progress(InstallProgress {
963            stage: InstallStage::Downloading,
964            message: "Installing Rust toolchain via rustup...".into(),
965            percent: Some(5),
966            elapsed: start.elapsed(),
967        });
968
969        // Install rustup
970        let rustup_script = r#"
971curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
972source ~/.cargo/env
973"#;
974
975        self.run_ssh_command(rustup_script, Duration::from_secs(300))?;
976
977        on_progress(InstallProgress {
978            stage: InstallStage::Compiling {
979                crate_name: CRATE_NAME.into(),
980            },
981            message: "Rust installed. Starting cargo install...".into(),
982            percent: Some(20),
983            elapsed: start.elapsed(),
984        });
985
986        // Now install cass via cargo
987        self.install_via_cargo(on_progress, start)
988    }
989
990    /// Poll for installation completion.
991    fn poll_installation<F>(
992        &self,
993        _pid: Option<u32>,
994        on_progress: &F,
995        start: Instant,
996    ) -> Result<(), InstallError>
997    where
998        F: Fn(InstallProgress),
999    {
1000        let poll_script = r#"
1001LOG_FILE=~/.cass_install.log
1002if [ -f "$LOG_FILE" ]; then
1003    if grep -q "===INSTALL_FAILED:" "$LOG_FILE"; then
1004        echo "STATUS=ERROR"
1005        tail -20 "$LOG_FILE"
1006    elif grep -q "===INSTALL_COMPLETE===" "$LOG_FILE"; then
1007        echo "STATUS=COMPLETE"
1008    elif grep -q "error\[" "$LOG_FILE" || grep -q "error:" "$LOG_FILE"; then
1009        echo "STATUS=ERROR"
1010        tail -20 "$LOG_FILE"
1011    else
1012        echo "STATUS=RUNNING"
1013        # Show last few lines of compilation progress
1014        tail -5 "$LOG_FILE" | grep -E "Compiling|Downloading|Installing" | tail -1
1015    fi
1016else
1017    echo "STATUS=NOT_STARTED"
1018fi
1019"#;
1020
1021        let max_wait = Duration::from_secs(600); // 10 minutes max
1022        let poll_interval = Duration::from_secs(5);
1023        let mut last_crate = String::new();
1024        let mut progress_pct: u8 = 15;
1025
1026        loop {
1027            if start.elapsed() > max_wait {
1028                return Err(InstallError::Timeout(max_wait.as_secs()));
1029            }
1030
1031            std::thread::sleep(poll_interval);
1032
1033            let output = self.run_ssh_command(poll_script, Duration::from_secs(30))?;
1034
1035            if install_poll_status(&output) == Some("COMPLETE") {
1036                return Ok(());
1037            }
1038
1039            if install_poll_status(&output) == Some("ERROR") {
1040                // Extract error message
1041                let error_lines: Vec<&str> = output
1042                    .lines()
1043                    .filter(|l| !l.trim_start().starts_with("STATUS="))
1044                    .collect();
1045                let error_msg = error_lines.join("\n");
1046
1047                // Check for common dependency issues
1048                if let Some(fix) = detect_missing_dependency(&error_msg) {
1049                    return Err(InstallError::MissingDependency {
1050                        dep: fix.0.to_string(),
1051                        fix: fix.1.to_string(),
1052                    });
1053                }
1054
1055                return Err(InstallError::MethodFailed {
1056                    method: "cargo install".into(),
1057                    reason: error_msg,
1058                });
1059            }
1060
1061            // Extract currently compiling crate
1062            for line in output.lines() {
1063                if line.contains("Compiling")
1064                    && let Some(crate_name) = line.split_whitespace().nth(1)
1065                    && crate_name != last_crate
1066                {
1067                    last_crate = crate_name.to_string();
1068                    progress_pct = (progress_pct + 3).min(85);
1069                }
1070            }
1071
1072            on_progress(InstallProgress {
1073                stage: InstallStage::Compiling {
1074                    crate_name: if last_crate.is_empty() {
1075                        "dependencies".into()
1076                    } else {
1077                        last_crate.clone()
1078                    },
1079                },
1080                message: format!(
1081                    "Compiling {}...",
1082                    if last_crate.is_empty() {
1083                        "dependencies"
1084                    } else {
1085                        &last_crate
1086                    }
1087                ),
1088                percent: Some(progress_pct),
1089                elapsed: start.elapsed(),
1090            });
1091        }
1092    }
1093
1094    /// Verify that cass was installed correctly.
1095    fn verify_installation<F>(&self, on_progress: &F, start: Instant) -> Result<(), InstallError>
1096    where
1097        F: Fn(InstallProgress),
1098    {
1099        on_progress(InstallProgress {
1100            stage: InstallStage::Verifying,
1101            message: "Verifying installation...".into(),
1102            percent: Some(90),
1103            elapsed: start.elapsed(),
1104        });
1105
1106        // Try to run cass --version
1107        let verify_script = r#"
1108source ~/.cargo/env 2>/dev/null || true
1109export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
1110cass --version 2>&1 || echo "VERIFY_FAILED"
1111"#;
1112
1113        let output = self.run_ssh_command(verify_script, Duration::from_secs(30))?;
1114
1115        if output_has_exact_line(&output, "VERIFY_FAILED") {
1116            return Err(InstallError::VerificationFailed(
1117                "cass --version failed".into(),
1118            ));
1119        }
1120
1121        // Check version matches
1122        if !output.contains(&self.target_version) {
1123            return Err(InstallError::VerificationFailed(format!(
1124                "Version mismatch: expected {}, got {}",
1125                self.target_version,
1126                output.trim()
1127            )));
1128        }
1129
1130        Ok(())
1131    }
1132
1133    /// Run an SSH command on the remote host.
1134    fn run_ssh_command(&self, script: &str, timeout: Duration) -> Result<String, InstallError> {
1135        let timeout_secs = timeout.as_secs().max(1);
1136
1137        let mut cmd = Command::new("ssh");
1138        cmd.args(strict_ssh_cli_tokens(timeout_secs.min(30)))
1139            .arg("-o")
1140            .arg("LogLevel=ERROR")
1141            .arg("--")
1142            .arg(&self.host)
1143            .arg("bash")
1144            .arg("-s");
1145
1146        cmd.stdin(Stdio::piped())
1147            .stdout(Stdio::piped())
1148            .stderr(Stdio::piped());
1149
1150        let mut child = cmd.spawn()?;
1151
1152        let write_error = if let Some(mut stdin) = child.stdin.take() {
1153            stdin.write_all(script.as_bytes()).err()
1154        } else {
1155            None
1156        };
1157
1158        let output = wait_for_child_output_with_timeout(child, timeout)?
1159            .ok_or(InstallError::Timeout(timeout_secs))?;
1160
1161        if !output.status.success() {
1162            let stderr = String::from_utf8_lossy(&output.stderr);
1163            if is_host_key_verification_failure(&stderr) {
1164                return Err(InstallError::SshFailed(host_key_verification_error(
1165                    &self.host,
1166                )));
1167            }
1168            if stderr.contains("Connection refused")
1169                || stderr.contains("Connection timed out")
1170                || stderr.contains("Permission denied")
1171            {
1172                return Err(InstallError::SshFailed(stderr.trim().to_string()));
1173            }
1174            // Fail fast on any other non-zero exit — surface the exit code and
1175            // stderr so operators can diagnose the root cause immediately.
1176            let code = output.status.code().unwrap_or(-1);
1177            return Err(InstallError::SshFailed(format!(
1178                "Remote script exited with code {code}: {}",
1179                stderr.trim()
1180            )));
1181        }
1182        if let Some(err) = write_error {
1183            return Err(InstallError::Io(err));
1184        }
1185
1186        Ok(String::from_utf8_lossy(&output.stdout).to_string())
1187    }
1188}
1189
1190/// Detect missing system dependencies from compilation errors.
1191fn detect_missing_dependency(error: &str) -> Option<(&'static str, &'static str)> {
1192    if error.contains("openssl") || error.contains("libssl") {
1193        Some((
1194            "OpenSSL development headers",
1195            "Ubuntu/Debian: sudo apt install libssl-dev pkg-config\nRHEL/CentOS: sudo yum install openssl-devel",
1196        ))
1197    } else if error.contains("cc") && error.contains("not found") {
1198        Some((
1199            "C compiler",
1200            "Ubuntu/Debian: sudo apt install build-essential\nRHEL/CentOS: sudo yum groupinstall 'Development Tools'",
1201        ))
1202    } else if error.contains("pkg-config") {
1203        Some((
1204            "pkg-config",
1205            "Ubuntu/Debian: sudo apt install pkg-config\nRHEL/CentOS: sudo yum install pkgconfig",
1206        ))
1207    } else {
1208        None
1209    }
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215
1216    fn fixture_system_info() -> SystemInfo {
1217        SystemInfo {
1218            os: "linux".into(),
1219            arch: "x86_64".into(),
1220            distro: Some("Ubuntu 24.04.1 LTS".into()),
1221            has_cargo: true,
1222            has_cargo_binstall: false,
1223            has_curl: true,
1224            has_wget: false,
1225            remote_home: "/home/user".into(),
1226            machine_id: None,
1227        }
1228    }
1229
1230    fn fixture_resources() -> ResourceInfo {
1231        ResourceInfo {
1232            disk_available_mb: 10000,
1233            memory_total_mb: 8000,
1234            memory_available_mb: 4000,
1235            can_compile: true,
1236        }
1237    }
1238
1239    #[test]
1240    fn test_install_method_display() {
1241        assert_eq!(
1242            InstallMethod::CargoBinstall.display_name(),
1243            "cargo-binstall"
1244        );
1245        assert_eq!(InstallMethod::CargoInstall.display_name(), "cargo install");
1246        assert_eq!(
1247            InstallMethod::FullBootstrap.display_name(),
1248            "full bootstrap (rustup + cargo)"
1249        );
1250    }
1251
1252    #[test]
1253    fn test_install_method_requires_compilation() {
1254        assert!(InstallMethod::CargoBinstall.requires_compilation());
1255        assert!(
1256            !InstallMethod::PrebuiltBinary {
1257                url: "".into(),
1258                checksum: None
1259            }
1260            .requires_compilation()
1261        );
1262        assert!(InstallMethod::CargoInstall.requires_compilation());
1263        assert!(InstallMethod::FullBootstrap.requires_compilation());
1264    }
1265
1266    #[test]
1267    fn test_install_resource_thresholds_match_probe_thresholds() {
1268        assert_eq!(MIN_DISK_MB, ResourceInfo::MIN_DISK_MB);
1269        assert_eq!(MIN_MEMORY_MB, ResourceInfo::MIN_MEMORY_MB);
1270    }
1271
1272    #[test]
1273    fn test_choose_method_prefers_binstall() {
1274        let mut system = fixture_system_info();
1275        system.has_cargo_binstall = true;
1276        let resources = fixture_resources();
1277
1278        let installer = RemoteInstaller::new("test", system, resources);
1279        assert_eq!(
1280            installer.choose_method(),
1281            Some(InstallMethod::CargoBinstall)
1282        );
1283    }
1284
1285    #[test]
1286    fn test_choose_method_skips_binstall_and_prebuilt_on_known_old_glibc_linux() {
1287        let mut system = fixture_system_info();
1288        system.distro = Some("Ubuntu 22.04.5 LTS".into());
1289        system.has_cargo_binstall = true;
1290        system.has_cargo = true;
1291        system.has_curl = true;
1292        let resources = fixture_resources();
1293
1294        let installer = RemoteInstaller::new("test", system, resources);
1295
1296        assert_eq!(
1297            installer.choose_method(),
1298            Some(InstallMethod::CargoInstall),
1299            "Ubuntu 22.04 is below the documented glibc requirement, so binary fast paths should fall through to source installs"
1300        );
1301    }
1302
1303    #[test]
1304    fn test_choose_method_cargo_install() {
1305        let mut system = fixture_system_info();
1306        // Disable curl/wget so pre-built binary is not available
1307        system.has_curl = false;
1308        system.has_wget = false;
1309        let resources = fixture_resources();
1310
1311        let installer = RemoteInstaller::new("test", system, resources);
1312        // With cargo but no binstall and no download tools, should choose cargo install
1313        assert_eq!(installer.choose_method(), Some(InstallMethod::CargoInstall));
1314    }
1315
1316    #[test]
1317    fn test_verified_prebuilt_binary_method_requires_checksum() {
1318        assert_eq!(
1319            RemoteInstaller::verified_prebuilt_binary_method(
1320                "https://example.com/cass.tar.gz".into(),
1321                None
1322            ),
1323            None
1324        );
1325    }
1326
1327    #[test]
1328    fn test_verified_prebuilt_binary_method_preserves_verified_fast_path() {
1329        let checksum = "a".repeat(64);
1330        assert_eq!(
1331            RemoteInstaller::verified_prebuilt_binary_method(
1332                "https://example.com/cass.tar.gz".into(),
1333                Some(checksum.clone()),
1334            ),
1335            Some(InstallMethod::PrebuiltBinary {
1336                url: "https://example.com/cass.tar.gz".into(),
1337                checksum: Some(checksum),
1338            })
1339        );
1340    }
1341
1342    #[test]
1343    fn test_choose_method_falls_back_to_cargo_when_prebuilt_checksum_is_unavailable() {
1344        let system = fixture_system_info();
1345        let resources = fixture_resources();
1346
1347        let installer = RemoteInstaller::new("test", system, resources);
1348        assert_eq!(
1349            installer.choose_method(),
1350            Some(InstallMethod::CargoInstall),
1351            "unverified prebuilt assets should be skipped in favor of a source install"
1352        );
1353    }
1354
1355    #[test]
1356    fn test_choose_method_bootstrap_when_no_cargo() {
1357        let mut system = fixture_system_info();
1358        system.has_cargo = false;
1359        // curl is needed for bootstrap (to download rustup)
1360        system.has_curl = true;
1361        system.has_wget = false;
1362        // Use unsupported arch so prebuilt binary is not available
1363        system.arch = "armv7".into();
1364        let resources = fixture_resources();
1365
1366        let installer = RemoteInstaller::new("test", system, resources);
1367        assert_eq!(
1368            installer.choose_method(),
1369            Some(InstallMethod::FullBootstrap)
1370        );
1371    }
1372
1373    #[test]
1374    fn test_choose_method_skips_bootstrap_when_compile_resources_are_insufficient() {
1375        let mut system = fixture_system_info();
1376        system.has_cargo = false;
1377        system.has_cargo_binstall = false;
1378        system.has_curl = true;
1379        system.has_wget = false;
1380        system.arch = "armv7".into();
1381        let mut resources = fixture_resources();
1382        resources.memory_total_mb = MIN_MEMORY_MB - 1;
1383
1384        let installer = RemoteInstaller::new("test", system, resources);
1385
1386        assert_eq!(
1387            installer.choose_method(),
1388            None,
1389            "full bootstrap should not be selected when it can only fail after installing rustup"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_choose_method_refuses_unverified_prebuilt_on_low_memory_hosts() {
1395        let mut system = fixture_system_info();
1396        system.has_cargo = false;
1397        system.has_cargo_binstall = false;
1398        system.has_curl = true;
1399        system.has_wget = false;
1400        let mut resources = fixture_resources();
1401        resources.memory_total_mb = MIN_MEMORY_MB - 1;
1402
1403        let installer = RemoteInstaller::new("test", system, resources);
1404
1405        assert_eq!(
1406            installer.choose_method(),
1407            None,
1408            "low-memory hosts should fail before mutation when the only compatible path is an unverified prebuilt install"
1409        );
1410    }
1411
1412    #[test]
1413    fn test_choose_method_bootstraps_instead_of_prebuilt_on_known_old_glibc_linux() {
1414        let mut system = fixture_system_info();
1415        system.distro = Some("Debian GNU/Linux 12 (bookworm)".into());
1416        system.has_cargo = false;
1417        system.has_cargo_binstall = false;
1418        system.has_curl = true;
1419        system.has_wget = false;
1420        let resources = fixture_resources();
1421
1422        let installer = RemoteInstaller::new("test", system, resources);
1423
1424        assert_eq!(
1425            installer.choose_method(),
1426            Some(InstallMethod::FullBootstrap),
1427            "known old-glibc Linux should avoid prebuilt binaries and bootstrap when no cargo exists"
1428        );
1429    }
1430
1431    #[test]
1432    fn test_choose_method_skips_binstall_when_compile_resources_are_insufficient() {
1433        let mut system = fixture_system_info();
1434        system.has_cargo = true;
1435        system.has_cargo_binstall = true;
1436        system.has_curl = false;
1437        system.has_wget = false;
1438        system.arch = "armv7".into();
1439        let mut resources = fixture_resources();
1440        resources.memory_total_mb = MIN_MEMORY_MB - 1;
1441
1442        let installer = RemoteInstaller::new("test", system, resources);
1443
1444        assert_eq!(
1445            installer.choose_method(),
1446            None,
1447            "cargo-binstall may fall back to cargo install, so it must not be selected when source builds are unsafe"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_choose_method_skips_low_memory_binstall_and_unverified_prebuilt() {
1453        let mut system = fixture_system_info();
1454        system.has_cargo = true;
1455        system.has_cargo_binstall = true;
1456        system.has_curl = true;
1457        system.has_wget = false;
1458        let mut resources = fixture_resources();
1459        resources.memory_total_mb = MIN_MEMORY_MB - 1;
1460
1461        let installer = RemoteInstaller::new("test", system, resources);
1462
1463        assert_eq!(
1464            installer.choose_method(),
1465            None,
1466            "low-memory hosts should not use cargo-binstall's source fallback or an unverified prebuilt install"
1467        );
1468    }
1469
1470    #[test]
1471    fn test_choose_method_none_when_no_tools() {
1472        let mut system = fixture_system_info();
1473        system.has_cargo = false;
1474        system.has_cargo_binstall = false;
1475        system.has_curl = false;
1476        system.has_wget = false;
1477        let resources = fixture_resources();
1478
1479        let installer = RemoteInstaller::new("test", system, resources);
1480        // No curl means no way to download rustup, no wget/curl means no prebuilt binary
1481        // No cargo means no cargo install - should return None
1482        assert_eq!(installer.choose_method(), None);
1483    }
1484
1485    #[test]
1486    fn test_check_resources_ok() {
1487        let system = fixture_system_info();
1488        let resources = fixture_resources();
1489
1490        let installer = RemoteInstaller::new("test", system, resources);
1491        assert!(installer.check_resources().is_ok());
1492    }
1493
1494    #[test]
1495    fn test_check_resources_insufficient_disk() {
1496        let system = fixture_system_info();
1497        let mut resources = fixture_resources();
1498        resources.disk_available_mb = 500;
1499
1500        let installer = RemoteInstaller::new("test", system, resources);
1501        let result = installer.check_resources();
1502        assert!(matches!(result, Err(InstallError::InsufficientDisk { .. })));
1503    }
1504
1505    #[test]
1506    fn test_can_compile_insufficient_memory() {
1507        let system = fixture_system_info();
1508        let mut resources = fixture_resources();
1509        resources.memory_total_mb = 512;
1510
1511        let installer = RemoteInstaller::new("test", system, resources);
1512        let result = installer.can_compile();
1513        assert!(matches!(
1514            result,
1515            Err(InstallError::InsufficientMemory { .. })
1516        ));
1517    }
1518
1519    #[test]
1520    fn test_get_prebuilt_url_linux_x86() {
1521        let system = fixture_system_info();
1522        let resources = fixture_resources();
1523
1524        let installer = RemoteInstaller::new("test", system, resources);
1525        let url = installer.get_prebuilt_url();
1526        assert!(url.is_some());
1527        assert!(url.unwrap().contains("linux-amd64.tar.gz"));
1528    }
1529
1530    #[test]
1531    fn test_get_prebuilt_url_skips_known_old_glibc_linux_distros() {
1532        for distro in [
1533            "Ubuntu 20.04.6 LTS",
1534            "Ubuntu 22.04.5 LTS",
1535            "Debian GNU/Linux 12 (bookworm)",
1536            "Fedora Linux 38 (Workstation Edition)",
1537            "CentOS Linux 7 (Core)",
1538            "Amazon Linux 2023",
1539            "Alpine Linux v3.20",
1540            "Void Linux",
1541            "NixOS 24.05 (Uakari)",
1542            "Pop!_OS 22.04 LTS",
1543            "Linux Mint 21.3 Virginia",
1544            "elementary OS 7.1 Horus",
1545            "Zorin OS 17.2 Core",
1546        ] {
1547            let mut system = fixture_system_info();
1548            system.distro = Some(distro.into());
1549            let resources = fixture_resources();
1550            let installer = RemoteInstaller::new("test", system, resources);
1551
1552            assert_eq!(
1553                installer.get_prebuilt_url(),
1554                None,
1555                "known old-glibc distro should not receive prebuilt binary: {distro}"
1556            );
1557        }
1558    }
1559
1560    #[test]
1561    fn test_get_prebuilt_url_allows_known_new_enough_linux_distros() {
1562        for distro in [
1563            "Ubuntu 24.04.1 LTS",
1564            "Debian GNU/Linux 13 (trixie)",
1565            "Fedora Linux 39 (Workstation Edition)",
1566            "Red Hat Enterprise Linux 10.0",
1567            "Pop!_OS 24.04 LTS",
1568            "Linux Mint 22 Wilma",
1569            "elementary OS 8 Circe",
1570            "Zorin OS 18 Core",
1571            "Arch Linux",
1572        ] {
1573            let mut system = fixture_system_info();
1574            system.distro = Some(distro.into());
1575            let resources = fixture_resources();
1576            let installer = RemoteInstaller::new("test", system, resources);
1577
1578            assert!(
1579                installer.get_prebuilt_url().is_some(),
1580                "compatible or unknown-glibc distro should keep prebuilt available: {distro}"
1581            );
1582        }
1583    }
1584
1585    #[test]
1586    fn test_get_prebuilt_url_macos_arm() {
1587        let mut system = fixture_system_info();
1588        system.os = "darwin".into();
1589        system.arch = "aarch64".into();
1590        let resources = fixture_resources();
1591
1592        let installer = RemoteInstaller::new("test", system, resources);
1593        let url = installer.get_prebuilt_url();
1594        assert!(url.is_some());
1595        assert!(url.unwrap().contains("darwin-arm64.tar.gz"));
1596    }
1597
1598    #[test]
1599    fn test_detect_missing_dependency_openssl() {
1600        let error = "error: failed to run custom build command for `openssl-sys`";
1601        let result = detect_missing_dependency(error);
1602        assert!(result.is_some());
1603        assert!(result.unwrap().0.contains("OpenSSL"));
1604    }
1605
1606    #[test]
1607    fn test_detect_missing_dependency_cc() {
1608        let error = "error: linker `cc` not found";
1609        let result = detect_missing_dependency(error);
1610        assert!(result.is_some());
1611        assert!(result.unwrap().0.contains("C compiler"));
1612    }
1613
1614    #[test]
1615    fn test_install_stage_display() {
1616        assert_eq!(InstallStage::Preparing.to_string(), "Preparing");
1617        assert_eq!(
1618            InstallStage::Compiling {
1619                crate_name: "tokio".into()
1620            }
1621            .to_string(),
1622            "Compiling tokio"
1623        );
1624        assert_eq!(InstallStage::Complete.to_string(), "Complete");
1625    }
1626
1627    #[test]
1628    fn test_install_poll_status_uses_structured_status_line() {
1629        assert_eq!(
1630            install_poll_status(
1631                "banner mentions STATUS=ERROR in prose\nSTATUS=COMPLETE\nCompiling cass\n",
1632            ),
1633            Some("COMPLETE")
1634        );
1635        assert_eq!(
1636            install_poll_status("STATUS=ERROR\nstartup banner\nSTATUS=COMPLETE\nCompiling cass\n"),
1637            Some("COMPLETE")
1638        );
1639        assert_eq!(
1640            install_poll_status("  STATUS=ERROR\nerror: failed\n"),
1641            Some("ERROR")
1642        );
1643        assert_eq!(install_poll_status("no structured status"), None);
1644    }
1645
1646    #[test]
1647    fn test_cargo_install_script_marks_failed_cargo_install_as_failed() {
1648        let system = fixture_system_info();
1649        let resources = fixture_resources();
1650        let installer = RemoteInstaller::new("test", system, resources);
1651        let script = installer.build_cargo_install_script();
1652
1653        assert!(
1654            script.contains("set -o pipefail"),
1655            "cargo install pipeline must preserve cargo's exit status"
1656        );
1657        assert!(
1658            script.contains("status=${PIPESTATUS[0]}"),
1659            "script must inspect cargo's side of `cargo install | tee`"
1660        );
1661        assert!(
1662            script.contains("===INSTALL_FAILED:${status}==="),
1663            "script must emit an explicit failed marker instead of always completing"
1664        );
1665        assert!(
1666            script.contains("exit \"$status\""),
1667            "background installer should exit with the cargo status"
1668        );
1669    }
1670
1671    #[test]
1672    fn test_verify_failed_marker_requires_exact_line() {
1673        assert!(!output_has_exact_line(
1674            "banner says VERIFY_FAILED is a marker\ncass 0.4.2\n",
1675            "VERIFY_FAILED"
1676        ));
1677        assert!(output_has_exact_line(
1678            "cass --version failed\nVERIFY_FAILED\n",
1679            "VERIFY_FAILED"
1680        ));
1681    }
1682
1683    // =========================================================================
1684    // Checksum verification tests
1685    // =========================================================================
1686
1687    #[test]
1688    fn test_get_checksum_url() {
1689        let binary_url =
1690            "https://github.com/example/repo/releases/download/v1.0.0/binary-linux-x86_64";
1691        let checksum_url = RemoteInstaller::get_checksum_url(binary_url);
1692        assert_eq!(
1693            checksum_url,
1694            "https://github.com/example/repo/releases/download/v1.0.0/binary-linux-x86_64.sha256"
1695        );
1696    }
1697
1698    #[test]
1699    fn test_checksum_urls_for_prebuilt_include_release_manifests() {
1700        let binary_url =
1701            "https://github.com/example/repo/releases/download/v1.0.0/cass-linux-amd64.tar.gz";
1702
1703        assert_eq!(
1704            RemoteInstaller::checksum_urls_for_prebuilt(binary_url),
1705            vec![
1706                "https://github.com/example/repo/releases/download/v1.0.0/cass-linux-amd64.tar.gz.sha256",
1707                "https://github.com/example/repo/releases/download/v1.0.0/SHA256SUMS.txt",
1708                "https://github.com/example/repo/releases/download/v1.0.0/SHA256SUMS",
1709            ]
1710        );
1711    }
1712
1713    #[test]
1714    fn test_parse_remote_checksum_output_matches_expected_manifest_asset() {
1715        let expected = "a".repeat(64);
1716        let other = "b".repeat(64);
1717        let manifest =
1718            format!("{other}  cass-darwin-arm64.tar.gz\n{expected}  cass-linux-amd64.tar.gz\n");
1719
1720        assert_eq!(
1721            RemoteInstaller::parse_remote_checksum_output(
1722                &manifest,
1723                Some("cass-linux-amd64.tar.gz")
1724            ),
1725            Some(expected)
1726        );
1727    }
1728
1729    #[test]
1730    fn test_parse_remote_checksum_output_rejects_wrong_manifest_asset() {
1731        let other = "b".repeat(64);
1732        let manifest = format!("{other}  cass-darwin-arm64.tar.gz\n");
1733
1734        assert_eq!(
1735            RemoteInstaller::parse_remote_checksum_output(
1736                &manifest,
1737                Some("cass-linux-amd64.tar.gz")
1738            ),
1739            None
1740        );
1741    }
1742
1743    #[test]
1744    fn test_parse_remote_checksum_output_accepts_per_file_checksum_line() {
1745        let expected = "A".repeat(64);
1746        let output = format!("{expected}  cass-linux-amd64.tar.gz\n");
1747
1748        assert_eq!(
1749            RemoteInstaller::parse_remote_checksum_output(&output, None),
1750            Some(expected.to_lowercase())
1751        );
1752    }
1753
1754    #[test]
1755    fn test_shell_quote_arg_suppresses_command_substitution() {
1756        assert_eq!(
1757            RemoteInstaller::shell_quote_arg("https://example.com/cass$(id).tar.gz"),
1758            "'https://example.com/cass$(id).tar.gz'"
1759        );
1760        assert_eq!(
1761            RemoteInstaller::shell_quote_arg("https://example.com/it's.tar.gz"),
1762            "'https://example.com/it'\\''s.tar.gz'"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_checksum_mismatch_error_display() {
1768        let err = InstallError::ChecksumMismatch {
1769            expected: "abc123".to_string(),
1770            actual: "def456".to_string(),
1771        };
1772        let msg = err.to_string();
1773        assert!(msg.contains("abc123"));
1774        assert!(msg.contains("def456"));
1775        assert!(msg.contains("mismatch"));
1776    }
1777
1778    #[test]
1779    fn test_checksum_validation_valid() {
1780        // Valid SHA256: 64 hex characters
1781        let valid = "a".repeat(64);
1782        assert_eq!(valid.len(), 64);
1783        assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
1784
1785        // Mixed case valid
1786        let mixed = "ABCDEFabcdef0123456789ABCDEFabcdef0123456789ABCDEFabcdef01234567";
1787        assert_eq!(mixed.len(), 64);
1788        assert!(mixed.chars().all(|c| c.is_ascii_hexdigit()));
1789    }
1790
1791    #[test]
1792    fn test_checksum_validation_invalid() {
1793        // Too short
1794        let short = "a".repeat(32);
1795        assert!(short.len() != 64);
1796
1797        // Too long
1798        let long = "a".repeat(128);
1799        assert!(long.len() != 64);
1800
1801        // Invalid characters
1802        let invalid = "g".repeat(64); // 'g' is not hex
1803        assert!(!invalid.chars().all(|c| c.is_ascii_hexdigit()));
1804    }
1805
1806    #[test]
1807    fn test_prebuilt_archive_member_policy_rejects_path_traversal() {
1808        assert!(RemoteInstaller::prebuilt_archive_member_is_allowed("cass"));
1809        assert!(RemoteInstaller::prebuilt_archive_member_is_allowed(
1810            "./cass"
1811        ));
1812
1813        for member in [
1814            "../cass",
1815            "payload/../cass",
1816            "/cass",
1817            "bin/cass",
1818            "cass/../../.ssh/authorized_keys",
1819            "./../cass",
1820            "cass\n../escape",
1821        ] {
1822            assert!(
1823                !RemoteInstaller::prebuilt_archive_member_is_allowed(member),
1824                "member should be rejected: {member:?}"
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    fn test_prebuilt_install_script_validates_tar_members_before_extract() {
1831        let script = RemoteInstaller::build_prebuilt_binary_install_script(
1832            "https://example.com/cass.tar.gz",
1833            &"a".repeat(64),
1834            true,
1835        );
1836        let list_index = script.find("tar -tzf").expect("tar listing validation");
1837        let extract_index = script.find("tar -xzf").expect("tar extraction");
1838
1839        assert!(
1840            list_index < extract_index,
1841            "archive members must be listed and validated before extraction"
1842        );
1843        assert!(script.contains("EXTRACT_UNSAFE"));
1844        assert!(script.contains("cass|./cass"));
1845        assert!(script.contains(r#"[ -L "${tmp_dir}/cass" ]"#));
1846        assert!(script.contains(r#"install -m 0755 "${tmp_dir}/cass""#));
1847        assert!(!script.contains("tar -xzf \"${archive_path}\" -C \"${tmp_dir}\"\n"));
1848    }
1849
1850    #[test]
1851    fn test_prebuilt_install_script_quotes_url_and_fails_without_checksum_tool() {
1852        let script = RemoteInstaller::build_prebuilt_binary_install_script(
1853            "https://example.com/cass'$(touch /tmp/pwned)'.tar.gz",
1854            &"a".repeat(64),
1855            true,
1856        );
1857
1858        assert!(
1859            script.contains(
1860                "curl -fsSL 'https://example.com/cass'\\''$(touch /tmp/pwned)'\\''.tar.gz'"
1861            )
1862        );
1863        assert!(script.contains("CHECKSUM_TOOL_MISSING"));
1864        assert!(!script.contains("skipping checksum"));
1865        assert!(!script.contains("actual_sum=\"aaaaaaaa"));
1866    }
1867
1868    #[test]
1869    fn test_prebuilt_binary_method_with_checksum() {
1870        let method = InstallMethod::PrebuiltBinary {
1871            url: "https://example.com/binary".to_string(),
1872            checksum: Some("a".repeat(64)),
1873        };
1874
1875        // Verify serialization includes checksum
1876        let json = serde_json::to_string(&method).unwrap();
1877        assert!(json.contains("checksum"));
1878        assert!(json.contains(&"a".repeat(64)));
1879
1880        // Verify deserialization
1881        let parsed: InstallMethod = serde_json::from_str(&json).unwrap();
1882        assert!(
1883            matches!(parsed, InstallMethod::PrebuiltBinary { .. }),
1884            "Expected PrebuiltBinary variant with checksum in test_prebuilt_binary_method_with_checksum"
1885        );
1886        if let InstallMethod::PrebuiltBinary { checksum, .. } = parsed {
1887            assert!(checksum.is_some());
1888            assert_eq!(checksum.unwrap().len(), 64);
1889        }
1890    }
1891
1892    #[test]
1893    fn test_prebuilt_binary_method_without_checksum() {
1894        let method = InstallMethod::PrebuiltBinary {
1895            url: "https://example.com/binary".to_string(),
1896            checksum: None,
1897        };
1898
1899        let json = serde_json::to_string(&method).unwrap();
1900        let parsed: InstallMethod = serde_json::from_str(&json).unwrap();
1901        assert!(
1902            matches!(parsed, InstallMethod::PrebuiltBinary { .. }),
1903            "Expected PrebuiltBinary variant in test_prebuilt_binary_method_without_checksum"
1904        );
1905        if let InstallMethod::PrebuiltBinary { checksum, .. } = parsed {
1906            assert!(checksum.is_none());
1907        }
1908    }
1909
1910    // =========================================================================
1911    // Real system probe integration tests — no mocks
1912    // =========================================================================
1913
1914    /// Build SystemInfo from real local system commands.
1915    fn local_system_info() -> SystemInfo {
1916        use std::process::Command;
1917
1918        let os = {
1919            let out = Command::new("uname").arg("-s").output().expect("uname -s");
1920            String::from_utf8_lossy(&out.stdout).trim().to_lowercase()
1921        };
1922        let arch = {
1923            let out = Command::new("uname").arg("-m").output().expect("uname -m");
1924            String::from_utf8_lossy(&out.stdout).trim().to_string()
1925        };
1926        let distro = if std::path::Path::new("/etc/os-release").exists() {
1927            let out = Command::new("bash")
1928                .arg("-c")
1929                .arg(". /etc/os-release && echo \"$PRETTY_NAME\"")
1930                .output()
1931                .ok();
1932            out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1933                .filter(|s| !s.is_empty())
1934        } else {
1935            None
1936        };
1937        let has = |cmd: &str| -> bool {
1938            Command::new("which")
1939                .arg(cmd)
1940                .output()
1941                .map(|o| o.status.success())
1942                .unwrap_or(false)
1943        };
1944        let home = dotenvy::var("HOME")
1945            .ok()
1946            .filter(|s| !s.is_empty())
1947            .or_else(|| {
1948                directories::BaseDirs::new().map(|d| d.home_dir().to_string_lossy().into_owned())
1949            })
1950            .unwrap_or_default();
1951
1952        SystemInfo {
1953            os,
1954            arch,
1955            distro,
1956            has_cargo: has("cargo"),
1957            has_cargo_binstall: has("cargo-binstall"),
1958            has_curl: has("curl"),
1959            has_wget: has("wget"),
1960            remote_home: home,
1961            machine_id: None, // Not needed in tests
1962        }
1963    }
1964
1965    /// Build ResourceInfo from real local system commands.
1966    fn local_resource_info() -> ResourceInfo {
1967        use std::process::Command;
1968
1969        let disk_mb = {
1970            let out = Command::new("bash")
1971                .arg("-c")
1972                // Avoid `~` tilde expansion since other tests mutate HOME concurrently.
1973                .arg("df -k / 2>/dev/null | awk 'NR==2 {print $4}'")
1974                .output()
1975                .expect("df -k /");
1976            let kb: u64 = String::from_utf8_lossy(&out.stdout)
1977                .trim()
1978                .parse()
1979                .unwrap_or(0);
1980            kb / 1024
1981        };
1982        let (mem_total_mb, mem_avail_mb) = if std::path::Path::new("/proc/meminfo").exists() {
1983            let out = Command::new("bash")
1984                .arg("-c")
1985                .arg("grep MemTotal /proc/meminfo | awk '{print $2}'")
1986                .output()
1987                .expect("memtotal");
1988            let total_kb: u64 = String::from_utf8_lossy(&out.stdout)
1989                .trim()
1990                .parse()
1991                .unwrap_or(0);
1992            let out2 = Command::new("bash")
1993                .arg("-c")
1994                .arg("grep MemAvailable /proc/meminfo | awk '{print $2}'")
1995                .output()
1996                .expect("memavail");
1997            let avail_kb: u64 = String::from_utf8_lossy(&out2.stdout)
1998                .trim()
1999                .parse()
2000                .unwrap_or(0);
2001            (total_kb / 1024, avail_kb / 1024)
2002        } else {
2003            // macOS fallback
2004            let out = Command::new("sysctl")
2005                .arg("-n")
2006                .arg("hw.memsize")
2007                .output()
2008                .ok();
2009            let bytes: u64 = out
2010                .map(|o| {
2011                    String::from_utf8_lossy(&o.stdout)
2012                        .trim()
2013                        .parse()
2014                        .unwrap_or(0)
2015                })
2016                .unwrap_or(0);
2017            let mb = bytes / (1024 * 1024);
2018            (mb, mb)
2019        };
2020
2021        ResourceInfo {
2022            disk_available_mb: disk_mb,
2023            memory_total_mb: mem_total_mb,
2024            memory_available_mb: mem_avail_mb,
2025            can_compile: disk_mb >= ResourceInfo::MIN_DISK_MB
2026                && mem_total_mb >= ResourceInfo::MIN_MEMORY_MB,
2027        }
2028    }
2029
2030    #[test]
2031    fn real_system_info_has_valid_fields() {
2032        let sys = local_system_info();
2033        assert!(
2034            sys.os == "linux" || sys.os == "darwin",
2035            "unexpected OS: {}",
2036            sys.os
2037        );
2038        assert!(!sys.arch.is_empty(), "arch should not be empty");
2039        assert!(!sys.remote_home.is_empty(), "home should not be empty");
2040        assert!(
2041            sys.remote_home.starts_with('/'),
2042            "home should be absolute: {}",
2043            sys.remote_home
2044        );
2045    }
2046
2047    #[test]
2048    fn real_resources_have_nonzero_values() {
2049        let res = local_resource_info();
2050        assert!(res.disk_available_mb > 0, "disk should be > 0");
2051        assert!(res.memory_total_mb > 0, "total memory should be > 0");
2052        assert!(
2053            res.memory_available_mb > 0,
2054            "available memory should be > 0"
2055        );
2056    }
2057
2058    #[test]
2059    fn real_resources_memory_invariant() {
2060        let res = local_resource_info();
2061        assert!(
2062            res.memory_available_mb <= res.memory_total_mb,
2063            "available ({}) > total ({})",
2064            res.memory_available_mb,
2065            res.memory_total_mb
2066        );
2067    }
2068
2069    #[test]
2070    fn real_resources_can_compile_matches_thresholds() {
2071        let res = local_resource_info();
2072        let expected = res.disk_available_mb >= ResourceInfo::MIN_DISK_MB
2073            && res.memory_total_mb >= ResourceInfo::MIN_MEMORY_MB;
2074        assert_eq!(
2075            res.can_compile, expected,
2076            "can_compile mismatch: disk={}MB mem={}MB",
2077            res.disk_available_mb, res.memory_total_mb
2078        );
2079    }
2080
2081    #[test]
2082    fn real_system_choose_method_returns_some() {
2083        let sys = local_system_info();
2084        let res = local_resource_info();
2085        // This system should have at least curl or cargo, so a method should exist
2086        let installer = RemoteInstaller::new("localhost", sys, res);
2087        let method = installer.choose_method();
2088        assert!(
2089            method.is_some(),
2090            "real system should have at least one install method"
2091        );
2092    }
2093
2094    #[test]
2095    #[ignore = "environment-dependent: requires >=2GB disk space"]
2096    fn real_system_check_resources_ok() {
2097        let sys = local_system_info();
2098        let res = local_resource_info();
2099        // This dev machine should have enough resources
2100        let installer = RemoteInstaller::new("localhost", sys, res);
2101        assert!(
2102            installer.check_resources().is_ok(),
2103            "dev machine should pass resource check"
2104        );
2105    }
2106
2107    #[test]
2108    #[ignore = "environment-dependent: requires >=2GB disk space and >=1GB memory"]
2109    fn real_system_can_compile_ok() {
2110        let sys = local_system_info();
2111        let res = local_resource_info();
2112        let installer = RemoteInstaller::new("localhost", sys, res);
2113        assert!(
2114            installer.can_compile().is_ok(),
2115            "dev machine should be able to compile"
2116        );
2117    }
2118
2119    #[test]
2120    fn real_system_prebuilt_url_valid() {
2121        let sys = local_system_info();
2122        let res = local_resource_info();
2123        let installer = RemoteInstaller::new("localhost", sys, res);
2124        if let Some(url) = installer.get_prebuilt_url() {
2125            assert!(url.starts_with("https://"), "URL should be https: {}", url);
2126            assert!(
2127                url.contains("linux") || url.contains("darwin"),
2128                "URL should contain OS: {}",
2129                url
2130            );
2131        }
2132        // Not all architectures have prebuilt URLs, so Some/None both acceptable
2133    }
2134
2135    #[test]
2136    fn real_system_tool_detection_consistent() {
2137        let sys = local_system_info();
2138        // If binstall is available, cargo must be too
2139        if sys.has_cargo_binstall {
2140            assert!(sys.has_cargo, "binstall requires cargo");
2141        }
2142        // Dev machine should have at least curl or wget
2143        assert!(
2144            sys.has_curl || sys.has_wget,
2145            "system should have at least one download tool"
2146        );
2147    }
2148}