1use 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 configure_child_process_group, 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
41pub const DEFAULT_INSTALL_TIMEOUT_SECS: u64 = 600; pub const MIN_DISK_MB: u64 = ResourceInfo::MIN_DISK_MB;
50
51pub const MIN_MEMORY_MB: u64 = ResourceInfo::MIN_MEMORY_MB;
53
54pub const CASS_VERSION: &str = env!("CARGO_PKG_VERSION");
56
57pub const CRATE_NAME: &str = "coding-agent-search";
59
60#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(tag = "method", rename_all = "snake_case")]
108pub enum InstallMethod {
109 CargoBinstall,
111
112 PrebuiltBinary {
114 url: String,
115 checksum: Option<String>,
116 },
117
118 CargoInstall,
120
121 FullBootstrap,
123}
124
125impl InstallMethod {
126 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 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), InstallMethod::FullBootstrap => Duration::from_secs(600), }
144 }
145
146 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum InstallStage {
171 Preparing,
173 Downloading,
175 Compiling { crate_name: String },
177 Installing,
179 Verifying,
181 Complete,
183 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#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct InstallProgress {
204 pub stage: InstallStage,
206 pub message: String,
208 pub percent: Option<u8>,
210 pub elapsed: Duration,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct InstallResult {
217 pub method: InstallMethod,
219 pub version: String,
221 pub duration: Duration,
223 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
261pub struct RemoteInstaller {
267 host: String,
269 system_info: SystemInfo,
271 resources: ResourceInfo,
273 target_version: String,
275}
276
277impl RemoteInstaller {
278 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 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 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 pub fn host(&self) -> &str {
331 &self.host
332 }
333
334 pub fn target_version(&self) -> &str {
336 &self.target_version
337 }
338
339 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 Ok(())
350 }
351
352 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 pub fn choose_method(&self) -> Option<InstallMethod> {
368 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 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 if self.system_info.has_cargo && self.can_compile().is_ok() {
391 return Some(InstallMethod::CargoInstall);
392 }
393
394 if self.system_info.has_curl && self.can_compile().is_ok() {
398 return Some(InstallMethod::FullBootstrap);
399 }
400
401 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 fn get_prebuilt_url(&self) -> Option<String> {
414 if !self.system_info.has_curl && !self.system_info.has_wget {
416 return None;
417 }
418
419 let arch = match self.system_info.arch.as_str() {
421 "x86_64" => "amd64",
422 "aarch64" | "arm64" => "arm64",
423 _ => return None, };
425
426 let os = match self.system_info.os.to_lowercase().as_str() {
427 "linux" => "linux",
428 "darwin" => "darwin",
429 _ => return None, };
431 if os == "linux" && !self.prebuilt_binary_fast_path_is_safe() {
432 return None;
433 }
434
435 if os == "darwin" && arch == "amd64" {
437 return None;
438 }
439
440 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 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 fn fetch_remote_checksum(
595 &self,
596 checksum_url: &str,
597 expected_asset: Option<&str>,
598 ) -> Option<String> {
599 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 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 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 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 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 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 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 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 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 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 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 #[allow(dead_code)] fn compute_remote_checksum(&self, remote_path: &str) -> Result<String, InstallError> {
841 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 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 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 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 let install_script = self.build_cargo_install_script();
899
900 let output = self.run_ssh_command(&install_script, Duration::from_secs(30))?;
902
903 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 self.poll_installation(pid, on_progress, start)?;
912
913 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 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 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 self.install_via_cargo(on_progress, start)
988 }
989
990 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); 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 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 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 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 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 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 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 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 configure_child_process_group(&mut cmd);
1150
1151 let mut child = cmd.spawn()?;
1152
1153 let write_error = if let Some(mut stdin) = child.stdin.take() {
1154 stdin.write_all(script.as_bytes()).err()
1155 } else {
1156 None
1157 };
1158
1159 let output = wait_for_child_output_with_timeout(child, timeout)?
1160 .ok_or(InstallError::Timeout(timeout_secs))?;
1161
1162 if !output.status.success() {
1163 let stderr = String::from_utf8_lossy(&output.stderr);
1164 if is_host_key_verification_failure(&stderr) {
1165 return Err(InstallError::SshFailed(host_key_verification_error(
1166 &self.host,
1167 )));
1168 }
1169 if stderr.contains("Connection refused")
1170 || stderr.contains("Connection timed out")
1171 || stderr.contains("Permission denied")
1172 {
1173 return Err(InstallError::SshFailed(stderr.trim().to_string()));
1174 }
1175 let code = output.status.code().unwrap_or(-1);
1178 return Err(InstallError::SshFailed(format!(
1179 "Remote script exited with code {code}: {}",
1180 stderr.trim()
1181 )));
1182 }
1183 if let Some(err) = write_error {
1184 return Err(InstallError::Io(err));
1185 }
1186
1187 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1188 }
1189}
1190
1191fn detect_missing_dependency(error: &str) -> Option<(&'static str, &'static str)> {
1193 if error.contains("openssl") || error.contains("libssl") {
1194 Some((
1195 "OpenSSL development headers",
1196 "Ubuntu/Debian: sudo apt install libssl-dev pkg-config\nRHEL/CentOS: sudo yum install openssl-devel",
1197 ))
1198 } else if error.contains("cc") && error.contains("not found") {
1199 Some((
1200 "C compiler",
1201 "Ubuntu/Debian: sudo apt install build-essential\nRHEL/CentOS: sudo yum groupinstall 'Development Tools'",
1202 ))
1203 } else if error.contains("pkg-config") {
1204 Some((
1205 "pkg-config",
1206 "Ubuntu/Debian: sudo apt install pkg-config\nRHEL/CentOS: sudo yum install pkgconfig",
1207 ))
1208 } else {
1209 None
1210 }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216
1217 fn fixture_system_info() -> SystemInfo {
1218 SystemInfo {
1219 os: "linux".into(),
1220 arch: "x86_64".into(),
1221 distro: Some("Ubuntu 24.04.1 LTS".into()),
1222 has_cargo: true,
1223 has_cargo_binstall: false,
1224 has_curl: true,
1225 has_wget: false,
1226 remote_home: "/home/user".into(),
1227 machine_id: None,
1228 }
1229 }
1230
1231 fn fixture_resources() -> ResourceInfo {
1232 ResourceInfo {
1233 disk_available_mb: 10000,
1234 memory_total_mb: 8000,
1235 memory_available_mb: 4000,
1236 can_compile: true,
1237 }
1238 }
1239
1240 #[test]
1241 fn test_install_method_display() {
1242 assert_eq!(
1243 InstallMethod::CargoBinstall.display_name(),
1244 "cargo-binstall"
1245 );
1246 assert_eq!(InstallMethod::CargoInstall.display_name(), "cargo install");
1247 assert_eq!(
1248 InstallMethod::FullBootstrap.display_name(),
1249 "full bootstrap (rustup + cargo)"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_install_method_requires_compilation() {
1255 assert!(InstallMethod::CargoBinstall.requires_compilation());
1256 assert!(
1257 !InstallMethod::PrebuiltBinary {
1258 url: "".into(),
1259 checksum: None
1260 }
1261 .requires_compilation()
1262 );
1263 assert!(InstallMethod::CargoInstall.requires_compilation());
1264 assert!(InstallMethod::FullBootstrap.requires_compilation());
1265 }
1266
1267 #[test]
1268 fn test_install_resource_thresholds_match_probe_thresholds() {
1269 assert_eq!(MIN_DISK_MB, ResourceInfo::MIN_DISK_MB);
1270 assert_eq!(MIN_MEMORY_MB, ResourceInfo::MIN_MEMORY_MB);
1271 }
1272
1273 #[test]
1274 fn test_choose_method_prefers_binstall() {
1275 let mut system = fixture_system_info();
1276 system.has_cargo_binstall = true;
1277 let resources = fixture_resources();
1278
1279 let installer = RemoteInstaller::new("test", system, resources);
1280 assert_eq!(
1281 installer.choose_method(),
1282 Some(InstallMethod::CargoBinstall)
1283 );
1284 }
1285
1286 #[test]
1287 fn test_choose_method_skips_binstall_and_prebuilt_on_known_old_glibc_linux() {
1288 let mut system = fixture_system_info();
1289 system.distro = Some("Ubuntu 22.04.5 LTS".into());
1290 system.has_cargo_binstall = true;
1291 system.has_cargo = true;
1292 system.has_curl = true;
1293 let resources = fixture_resources();
1294
1295 let installer = RemoteInstaller::new("test", system, resources);
1296
1297 assert_eq!(
1298 installer.choose_method(),
1299 Some(InstallMethod::CargoInstall),
1300 "Ubuntu 22.04 is below the documented glibc requirement, so binary fast paths should fall through to source installs"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_choose_method_cargo_install() {
1306 let mut system = fixture_system_info();
1307 system.has_curl = false;
1309 system.has_wget = false;
1310 let resources = fixture_resources();
1311
1312 let installer = RemoteInstaller::new("test", system, resources);
1313 assert_eq!(installer.choose_method(), Some(InstallMethod::CargoInstall));
1315 }
1316
1317 #[test]
1318 fn test_verified_prebuilt_binary_method_requires_checksum() {
1319 assert_eq!(
1320 RemoteInstaller::verified_prebuilt_binary_method(
1321 "https://example.com/cass.tar.gz".into(),
1322 None
1323 ),
1324 None
1325 );
1326 }
1327
1328 #[test]
1329 fn test_verified_prebuilt_binary_method_preserves_verified_fast_path() {
1330 let checksum = "a".repeat(64);
1331 assert_eq!(
1332 RemoteInstaller::verified_prebuilt_binary_method(
1333 "https://example.com/cass.tar.gz".into(),
1334 Some(checksum.clone()),
1335 ),
1336 Some(InstallMethod::PrebuiltBinary {
1337 url: "https://example.com/cass.tar.gz".into(),
1338 checksum: Some(checksum),
1339 })
1340 );
1341 }
1342
1343 #[test]
1344 fn test_choose_method_falls_back_to_cargo_when_prebuilt_checksum_is_unavailable() {
1345 let system = fixture_system_info();
1346 let resources = fixture_resources();
1347
1348 let installer = RemoteInstaller::new("test", system, resources);
1349 assert_eq!(
1350 installer.choose_method(),
1351 Some(InstallMethod::CargoInstall),
1352 "unverified prebuilt assets should be skipped in favor of a source install"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_choose_method_bootstrap_when_no_cargo() {
1358 let mut system = fixture_system_info();
1359 system.has_cargo = false;
1360 system.has_curl = true;
1362 system.has_wget = false;
1363 system.arch = "armv7".into();
1365 let resources = fixture_resources();
1366
1367 let installer = RemoteInstaller::new("test", system, resources);
1368 assert_eq!(
1369 installer.choose_method(),
1370 Some(InstallMethod::FullBootstrap)
1371 );
1372 }
1373
1374 #[test]
1375 fn test_choose_method_skips_bootstrap_when_compile_resources_are_insufficient() {
1376 let mut system = fixture_system_info();
1377 system.has_cargo = false;
1378 system.has_cargo_binstall = false;
1379 system.has_curl = true;
1380 system.has_wget = false;
1381 system.arch = "armv7".into();
1382 let mut resources = fixture_resources();
1383 resources.memory_total_mb = MIN_MEMORY_MB - 1;
1384
1385 let installer = RemoteInstaller::new("test", system, resources);
1386
1387 assert_eq!(
1388 installer.choose_method(),
1389 None,
1390 "full bootstrap should not be selected when it can only fail after installing rustup"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_choose_method_refuses_unverified_prebuilt_on_low_memory_hosts() {
1396 let mut system = fixture_system_info();
1397 system.has_cargo = false;
1398 system.has_cargo_binstall = false;
1399 system.has_curl = true;
1400 system.has_wget = false;
1401 let mut resources = fixture_resources();
1402 resources.memory_total_mb = MIN_MEMORY_MB - 1;
1403
1404 let installer = RemoteInstaller::new("test", system, resources);
1405
1406 assert_eq!(
1407 installer.choose_method(),
1408 None,
1409 "low-memory hosts should fail before mutation when the only compatible path is an unverified prebuilt install"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_choose_method_bootstraps_instead_of_prebuilt_on_known_old_glibc_linux() {
1415 let mut system = fixture_system_info();
1416 system.distro = Some("Debian GNU/Linux 12 (bookworm)".into());
1417 system.has_cargo = false;
1418 system.has_cargo_binstall = false;
1419 system.has_curl = true;
1420 system.has_wget = false;
1421 let resources = fixture_resources();
1422
1423 let installer = RemoteInstaller::new("test", system, resources);
1424
1425 assert_eq!(
1426 installer.choose_method(),
1427 Some(InstallMethod::FullBootstrap),
1428 "known old-glibc Linux should avoid prebuilt binaries and bootstrap when no cargo exists"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_choose_method_skips_binstall_when_compile_resources_are_insufficient() {
1434 let mut system = fixture_system_info();
1435 system.has_cargo = true;
1436 system.has_cargo_binstall = true;
1437 system.has_curl = false;
1438 system.has_wget = false;
1439 system.arch = "armv7".into();
1440 let mut resources = fixture_resources();
1441 resources.memory_total_mb = MIN_MEMORY_MB - 1;
1442
1443 let installer = RemoteInstaller::new("test", system, resources);
1444
1445 assert_eq!(
1446 installer.choose_method(),
1447 None,
1448 "cargo-binstall may fall back to cargo install, so it must not be selected when source builds are unsafe"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_choose_method_skips_low_memory_binstall_and_unverified_prebuilt() {
1454 let mut system = fixture_system_info();
1455 system.has_cargo = true;
1456 system.has_cargo_binstall = true;
1457 system.has_curl = true;
1458 system.has_wget = false;
1459 let mut resources = fixture_resources();
1460 resources.memory_total_mb = MIN_MEMORY_MB - 1;
1461
1462 let installer = RemoteInstaller::new("test", system, resources);
1463
1464 assert_eq!(
1465 installer.choose_method(),
1466 None,
1467 "low-memory hosts should not use cargo-binstall's source fallback or an unverified prebuilt install"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_choose_method_none_when_no_tools() {
1473 let mut system = fixture_system_info();
1474 system.has_cargo = false;
1475 system.has_cargo_binstall = false;
1476 system.has_curl = false;
1477 system.has_wget = false;
1478 let resources = fixture_resources();
1479
1480 let installer = RemoteInstaller::new("test", system, resources);
1481 assert_eq!(installer.choose_method(), None);
1484 }
1485
1486 #[test]
1487 fn test_check_resources_ok() {
1488 let system = fixture_system_info();
1489 let resources = fixture_resources();
1490
1491 let installer = RemoteInstaller::new("test", system, resources);
1492 assert!(installer.check_resources().is_ok());
1493 }
1494
1495 #[test]
1496 fn test_check_resources_insufficient_disk() {
1497 let system = fixture_system_info();
1498 let mut resources = fixture_resources();
1499 resources.disk_available_mb = 500;
1500
1501 let installer = RemoteInstaller::new("test", system, resources);
1502 let result = installer.check_resources();
1503 assert!(matches!(result, Err(InstallError::InsufficientDisk { .. })));
1504 }
1505
1506 #[test]
1507 fn test_can_compile_insufficient_memory() {
1508 let system = fixture_system_info();
1509 let mut resources = fixture_resources();
1510 resources.memory_total_mb = 512;
1511
1512 let installer = RemoteInstaller::new("test", system, resources);
1513 let result = installer.can_compile();
1514 assert!(matches!(
1515 result,
1516 Err(InstallError::InsufficientMemory { .. })
1517 ));
1518 }
1519
1520 #[test]
1521 fn test_get_prebuilt_url_linux_x86() {
1522 let system = fixture_system_info();
1523 let resources = fixture_resources();
1524
1525 let installer = RemoteInstaller::new("test", system, resources);
1526 let url = installer.get_prebuilt_url();
1527 assert!(url.is_some());
1528 assert!(url.unwrap().contains("linux-amd64.tar.gz"));
1529 }
1530
1531 #[test]
1532 fn test_get_prebuilt_url_skips_known_old_glibc_linux_distros() {
1533 for distro in [
1534 "Ubuntu 20.04.6 LTS",
1535 "Ubuntu 22.04.5 LTS",
1536 "Debian GNU/Linux 12 (bookworm)",
1537 "Fedora Linux 38 (Workstation Edition)",
1538 "CentOS Linux 7 (Core)",
1539 "Amazon Linux 2023",
1540 "Alpine Linux v3.20",
1541 "Void Linux",
1542 "NixOS 24.05 (Uakari)",
1543 "Pop!_OS 22.04 LTS",
1544 "Linux Mint 21.3 Virginia",
1545 "elementary OS 7.1 Horus",
1546 "Zorin OS 17.2 Core",
1547 ] {
1548 let mut system = fixture_system_info();
1549 system.distro = Some(distro.into());
1550 let resources = fixture_resources();
1551 let installer = RemoteInstaller::new("test", system, resources);
1552
1553 assert_eq!(
1554 installer.get_prebuilt_url(),
1555 None,
1556 "known old-glibc distro should not receive prebuilt binary: {distro}"
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 fn test_get_prebuilt_url_allows_known_new_enough_linux_distros() {
1563 for distro in [
1564 "Ubuntu 24.04.1 LTS",
1565 "Debian GNU/Linux 13 (trixie)",
1566 "Fedora Linux 39 (Workstation Edition)",
1567 "Red Hat Enterprise Linux 10.0",
1568 "Pop!_OS 24.04 LTS",
1569 "Linux Mint 22 Wilma",
1570 "elementary OS 8 Circe",
1571 "Zorin OS 18 Core",
1572 "Arch Linux",
1573 ] {
1574 let mut system = fixture_system_info();
1575 system.distro = Some(distro.into());
1576 let resources = fixture_resources();
1577 let installer = RemoteInstaller::new("test", system, resources);
1578
1579 assert!(
1580 installer.get_prebuilt_url().is_some(),
1581 "compatible or unknown-glibc distro should keep prebuilt available: {distro}"
1582 );
1583 }
1584 }
1585
1586 #[test]
1587 fn test_get_prebuilt_url_macos_arm() {
1588 let mut system = fixture_system_info();
1589 system.os = "darwin".into();
1590 system.arch = "aarch64".into();
1591 let resources = fixture_resources();
1592
1593 let installer = RemoteInstaller::new("test", system, resources);
1594 let url = installer.get_prebuilt_url();
1595 assert!(url.is_some());
1596 assert!(url.unwrap().contains("darwin-arm64.tar.gz"));
1597 }
1598
1599 #[test]
1600 fn test_detect_missing_dependency_openssl() {
1601 let error = "error: failed to run custom build command for `openssl-sys`";
1602 let result = detect_missing_dependency(error);
1603 assert!(result.is_some());
1604 assert!(result.unwrap().0.contains("OpenSSL"));
1605 }
1606
1607 #[test]
1608 fn test_detect_missing_dependency_cc() {
1609 let error = "error: linker `cc` not found";
1610 let result = detect_missing_dependency(error);
1611 assert!(result.is_some());
1612 assert!(result.unwrap().0.contains("C compiler"));
1613 }
1614
1615 #[test]
1616 fn test_install_stage_display() {
1617 assert_eq!(InstallStage::Preparing.to_string(), "Preparing");
1618 assert_eq!(
1619 InstallStage::Compiling {
1620 crate_name: "tokio".into()
1621 }
1622 .to_string(),
1623 "Compiling tokio"
1624 );
1625 assert_eq!(InstallStage::Complete.to_string(), "Complete");
1626 }
1627
1628 #[test]
1629 fn test_install_poll_status_uses_structured_status_line() {
1630 assert_eq!(
1631 install_poll_status(
1632 "banner mentions STATUS=ERROR in prose\nSTATUS=COMPLETE\nCompiling cass\n",
1633 ),
1634 Some("COMPLETE")
1635 );
1636 assert_eq!(
1637 install_poll_status("STATUS=ERROR\nstartup banner\nSTATUS=COMPLETE\nCompiling cass\n"),
1638 Some("COMPLETE")
1639 );
1640 assert_eq!(
1641 install_poll_status(" STATUS=ERROR\nerror: failed\n"),
1642 Some("ERROR")
1643 );
1644 assert_eq!(install_poll_status("no structured status"), None);
1645 }
1646
1647 #[test]
1648 fn test_cargo_install_script_marks_failed_cargo_install_as_failed() {
1649 let system = fixture_system_info();
1650 let resources = fixture_resources();
1651 let installer = RemoteInstaller::new("test", system, resources);
1652 let script = installer.build_cargo_install_script();
1653
1654 assert!(
1655 script.contains("set -o pipefail"),
1656 "cargo install pipeline must preserve cargo's exit status"
1657 );
1658 assert!(
1659 script.contains("status=${PIPESTATUS[0]}"),
1660 "script must inspect cargo's side of `cargo install | tee`"
1661 );
1662 assert!(
1663 script.contains("===INSTALL_FAILED:${status}==="),
1664 "script must emit an explicit failed marker instead of always completing"
1665 );
1666 assert!(
1667 script.contains("exit \"$status\""),
1668 "background installer should exit with the cargo status"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_verify_failed_marker_requires_exact_line() {
1674 assert!(!output_has_exact_line(
1675 "banner says VERIFY_FAILED is a marker\ncass 0.4.2\n",
1676 "VERIFY_FAILED"
1677 ));
1678 assert!(output_has_exact_line(
1679 "cass --version failed\nVERIFY_FAILED\n",
1680 "VERIFY_FAILED"
1681 ));
1682 }
1683
1684 #[test]
1689 fn test_get_checksum_url() {
1690 let binary_url =
1691 "https://github.com/example/repo/releases/download/v1.0.0/binary-linux-x86_64";
1692 let checksum_url = RemoteInstaller::get_checksum_url(binary_url);
1693 assert_eq!(
1694 checksum_url,
1695 "https://github.com/example/repo/releases/download/v1.0.0/binary-linux-x86_64.sha256"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_checksum_urls_for_prebuilt_include_release_manifests() {
1701 let binary_url =
1702 "https://github.com/example/repo/releases/download/v1.0.0/cass-linux-amd64.tar.gz";
1703
1704 assert_eq!(
1705 RemoteInstaller::checksum_urls_for_prebuilt(binary_url),
1706 vec![
1707 "https://github.com/example/repo/releases/download/v1.0.0/cass-linux-amd64.tar.gz.sha256",
1708 "https://github.com/example/repo/releases/download/v1.0.0/SHA256SUMS.txt",
1709 "https://github.com/example/repo/releases/download/v1.0.0/SHA256SUMS",
1710 ]
1711 );
1712 }
1713
1714 #[test]
1715 fn test_parse_remote_checksum_output_matches_expected_manifest_asset() {
1716 let expected = "a".repeat(64);
1717 let other = "b".repeat(64);
1718 let manifest =
1719 format!("{other} cass-darwin-arm64.tar.gz\n{expected} cass-linux-amd64.tar.gz\n");
1720
1721 assert_eq!(
1722 RemoteInstaller::parse_remote_checksum_output(
1723 &manifest,
1724 Some("cass-linux-amd64.tar.gz")
1725 ),
1726 Some(expected)
1727 );
1728 }
1729
1730 #[test]
1731 fn test_parse_remote_checksum_output_rejects_wrong_manifest_asset() {
1732 let other = "b".repeat(64);
1733 let manifest = format!("{other} cass-darwin-arm64.tar.gz\n");
1734
1735 assert_eq!(
1736 RemoteInstaller::parse_remote_checksum_output(
1737 &manifest,
1738 Some("cass-linux-amd64.tar.gz")
1739 ),
1740 None
1741 );
1742 }
1743
1744 #[test]
1745 fn test_parse_remote_checksum_output_accepts_per_file_checksum_line() {
1746 let expected = "A".repeat(64);
1747 let output = format!("{expected} cass-linux-amd64.tar.gz\n");
1748
1749 assert_eq!(
1750 RemoteInstaller::parse_remote_checksum_output(&output, None),
1751 Some(expected.to_lowercase())
1752 );
1753 }
1754
1755 #[test]
1756 fn test_shell_quote_arg_suppresses_command_substitution() {
1757 assert_eq!(
1758 RemoteInstaller::shell_quote_arg("https://example.com/cass$(id).tar.gz"),
1759 "'https://example.com/cass$(id).tar.gz'"
1760 );
1761 assert_eq!(
1762 RemoteInstaller::shell_quote_arg("https://example.com/it's.tar.gz"),
1763 "'https://example.com/it'\\''s.tar.gz'"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_checksum_mismatch_error_display() {
1769 let err = InstallError::ChecksumMismatch {
1770 expected: "abc123".to_string(),
1771 actual: "def456".to_string(),
1772 };
1773 let msg = err.to_string();
1774 assert!(msg.contains("abc123"));
1775 assert!(msg.contains("def456"));
1776 assert!(msg.contains("mismatch"));
1777 }
1778
1779 #[test]
1780 fn test_checksum_validation_valid() {
1781 let valid = "a".repeat(64);
1783 assert_eq!(valid.len(), 64);
1784 assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
1785
1786 let mixed = "ABCDEFabcdef0123456789ABCDEFabcdef0123456789ABCDEFabcdef01234567";
1788 assert_eq!(mixed.len(), 64);
1789 assert!(mixed.chars().all(|c| c.is_ascii_hexdigit()));
1790 }
1791
1792 #[test]
1793 fn test_checksum_validation_invalid() {
1794 let short = "a".repeat(32);
1796 assert!(short.len() != 64);
1797
1798 let long = "a".repeat(128);
1800 assert!(long.len() != 64);
1801
1802 let invalid = "g".repeat(64); assert!(!invalid.chars().all(|c| c.is_ascii_hexdigit()));
1805 }
1806
1807 #[test]
1808 fn test_prebuilt_archive_member_policy_rejects_path_traversal() {
1809 assert!(RemoteInstaller::prebuilt_archive_member_is_allowed("cass"));
1810 assert!(RemoteInstaller::prebuilt_archive_member_is_allowed(
1811 "./cass"
1812 ));
1813
1814 for member in [
1815 "../cass",
1816 "payload/../cass",
1817 "/cass",
1818 "bin/cass",
1819 "cass/../../.ssh/authorized_keys",
1820 "./../cass",
1821 "cass\n../escape",
1822 ] {
1823 assert!(
1824 !RemoteInstaller::prebuilt_archive_member_is_allowed(member),
1825 "member should be rejected: {member:?}"
1826 );
1827 }
1828 }
1829
1830 #[test]
1831 fn test_prebuilt_install_script_validates_tar_members_before_extract() {
1832 let script = RemoteInstaller::build_prebuilt_binary_install_script(
1833 "https://example.com/cass.tar.gz",
1834 &"a".repeat(64),
1835 true,
1836 );
1837 let list_index = script.find("tar -tzf").expect("tar listing validation");
1838 let extract_index = script.find("tar -xzf").expect("tar extraction");
1839
1840 assert!(
1841 list_index < extract_index,
1842 "archive members must be listed and validated before extraction"
1843 );
1844 assert!(script.contains("EXTRACT_UNSAFE"));
1845 assert!(script.contains("cass|./cass"));
1846 assert!(script.contains(r#"[ -L "${tmp_dir}/cass" ]"#));
1847 assert!(script.contains(r#"install -m 0755 "${tmp_dir}/cass""#));
1848 assert!(!script.contains("tar -xzf \"${archive_path}\" -C \"${tmp_dir}\"\n"));
1849 }
1850
1851 #[test]
1852 fn test_prebuilt_install_script_quotes_url_and_fails_without_checksum_tool() {
1853 let script = RemoteInstaller::build_prebuilt_binary_install_script(
1854 "https://example.com/cass'$(touch /tmp/pwned)'.tar.gz",
1855 &"a".repeat(64),
1856 true,
1857 );
1858
1859 assert!(
1860 script.contains(
1861 "curl -fsSL 'https://example.com/cass'\\''$(touch /tmp/pwned)'\\''.tar.gz'"
1862 )
1863 );
1864 assert!(script.contains("CHECKSUM_TOOL_MISSING"));
1865 assert!(!script.contains("skipping checksum"));
1866 assert!(!script.contains("actual_sum=\"aaaaaaaa"));
1867 }
1868
1869 #[test]
1870 fn test_prebuilt_binary_method_with_checksum() {
1871 let method = InstallMethod::PrebuiltBinary {
1872 url: "https://example.com/binary".to_string(),
1873 checksum: Some("a".repeat(64)),
1874 };
1875
1876 let json = serde_json::to_string(&method).unwrap();
1878 assert!(json.contains("checksum"));
1879 assert!(json.contains(&"a".repeat(64)));
1880
1881 let parsed: InstallMethod = serde_json::from_str(&json).unwrap();
1883 assert!(
1884 matches!(parsed, InstallMethod::PrebuiltBinary { .. }),
1885 "Expected PrebuiltBinary variant with checksum in test_prebuilt_binary_method_with_checksum"
1886 );
1887 if let InstallMethod::PrebuiltBinary { checksum, .. } = parsed {
1888 assert!(checksum.is_some());
1889 assert_eq!(checksum.unwrap().len(), 64);
1890 }
1891 }
1892
1893 #[test]
1894 fn test_prebuilt_binary_method_without_checksum() {
1895 let method = InstallMethod::PrebuiltBinary {
1896 url: "https://example.com/binary".to_string(),
1897 checksum: None,
1898 };
1899
1900 let json = serde_json::to_string(&method).unwrap();
1901 let parsed: InstallMethod = serde_json::from_str(&json).unwrap();
1902 assert!(
1903 matches!(parsed, InstallMethod::PrebuiltBinary { .. }),
1904 "Expected PrebuiltBinary variant in test_prebuilt_binary_method_without_checksum"
1905 );
1906 if let InstallMethod::PrebuiltBinary { checksum, .. } = parsed {
1907 assert!(checksum.is_none());
1908 }
1909 }
1910
1911 fn local_system_info() -> SystemInfo {
1917 use std::process::Command;
1918
1919 let os = {
1920 let out = Command::new("uname").arg("-s").output().expect("uname -s");
1921 String::from_utf8_lossy(&out.stdout).trim().to_lowercase()
1922 };
1923 let arch = {
1924 let out = Command::new("uname").arg("-m").output().expect("uname -m");
1925 String::from_utf8_lossy(&out.stdout).trim().to_string()
1926 };
1927 let distro = if std::path::Path::new("/etc/os-release").exists() {
1928 let out = Command::new("bash")
1929 .arg("-c")
1930 .arg(". /etc/os-release && echo \"$PRETTY_NAME\"")
1931 .output()
1932 .ok();
1933 out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
1934 .filter(|s| !s.is_empty())
1935 } else {
1936 None
1937 };
1938 let has = |cmd: &str| -> bool {
1939 Command::new("which")
1940 .arg(cmd)
1941 .output()
1942 .map(|o| o.status.success())
1943 .unwrap_or(false)
1944 };
1945 let home = dotenvy::var("HOME")
1946 .ok()
1947 .filter(|s| !s.is_empty())
1948 .or_else(|| {
1949 directories::BaseDirs::new().map(|d| d.home_dir().to_string_lossy().into_owned())
1950 })
1951 .unwrap_or_default();
1952
1953 SystemInfo {
1954 os,
1955 arch,
1956 distro,
1957 has_cargo: has("cargo"),
1958 has_cargo_binstall: has("cargo-binstall"),
1959 has_curl: has("curl"),
1960 has_wget: has("wget"),
1961 remote_home: home,
1962 machine_id: None, }
1964 }
1965
1966 fn local_resource_info() -> ResourceInfo {
1968 use std::process::Command;
1969
1970 let disk_mb = {
1971 let out = Command::new("bash")
1972 .arg("-c")
1973 .arg("df -k / 2>/dev/null | awk 'NR==2 {print $4}'")
1975 .output()
1976 .expect("df -k /");
1977 let kb: u64 = String::from_utf8_lossy(&out.stdout)
1978 .trim()
1979 .parse()
1980 .unwrap_or(0);
1981 kb / 1024
1982 };
1983 let (mem_total_mb, mem_avail_mb) = if std::path::Path::new("/proc/meminfo").exists() {
1984 let out = Command::new("bash")
1985 .arg("-c")
1986 .arg("grep MemTotal /proc/meminfo | awk '{print $2}'")
1987 .output()
1988 .expect("memtotal");
1989 let total_kb: u64 = String::from_utf8_lossy(&out.stdout)
1990 .trim()
1991 .parse()
1992 .unwrap_or(0);
1993 let out2 = Command::new("bash")
1994 .arg("-c")
1995 .arg("grep MemAvailable /proc/meminfo | awk '{print $2}'")
1996 .output()
1997 .expect("memavail");
1998 let avail_kb: u64 = String::from_utf8_lossy(&out2.stdout)
1999 .trim()
2000 .parse()
2001 .unwrap_or(0);
2002 (total_kb / 1024, avail_kb / 1024)
2003 } else {
2004 let out = Command::new("sysctl")
2006 .arg("-n")
2007 .arg("hw.memsize")
2008 .output()
2009 .ok();
2010 let bytes: u64 = out
2011 .map(|o| {
2012 String::from_utf8_lossy(&o.stdout)
2013 .trim()
2014 .parse()
2015 .unwrap_or(0)
2016 })
2017 .unwrap_or(0);
2018 let mb = bytes / (1024 * 1024);
2019 (mb, mb)
2020 };
2021
2022 ResourceInfo {
2023 disk_available_mb: disk_mb,
2024 memory_total_mb: mem_total_mb,
2025 memory_available_mb: mem_avail_mb,
2026 can_compile: disk_mb >= ResourceInfo::MIN_DISK_MB
2027 && mem_total_mb >= ResourceInfo::MIN_MEMORY_MB,
2028 }
2029 }
2030
2031 #[test]
2032 #[cfg(not(windows))]
2033 fn real_system_info_has_valid_fields() {
2034 let sys = local_system_info();
2035 assert!(
2036 sys.os == "linux" || sys.os == "darwin",
2037 "unexpected OS: {}",
2038 sys.os
2039 );
2040 assert!(!sys.arch.is_empty(), "arch should not be empty");
2041 assert!(!sys.remote_home.is_empty(), "home should not be empty");
2042 assert!(
2043 sys.remote_home.starts_with('/'),
2044 "home should be absolute: {}",
2045 sys.remote_home
2046 );
2047 }
2048
2049 #[test]
2050 #[cfg(not(windows))]
2051 fn real_resources_have_nonzero_values() {
2052 let res = local_resource_info();
2053 assert!(res.disk_available_mb > 0, "disk should be > 0");
2054 assert!(res.memory_total_mb > 0, "total memory should be > 0");
2055 assert!(
2056 res.memory_available_mb > 0,
2057 "available memory should be > 0"
2058 );
2059 }
2060
2061 #[test]
2062 #[cfg(not(windows))]
2063 fn real_resources_memory_invariant() {
2064 let res = local_resource_info();
2065 assert!(
2066 res.memory_available_mb <= res.memory_total_mb,
2067 "available ({}) > total ({})",
2068 res.memory_available_mb,
2069 res.memory_total_mb
2070 );
2071 }
2072
2073 #[test]
2074 #[cfg(not(windows))]
2075 fn real_resources_can_compile_matches_thresholds() {
2076 let res = local_resource_info();
2077 let expected = res.disk_available_mb >= ResourceInfo::MIN_DISK_MB
2078 && res.memory_total_mb >= ResourceInfo::MIN_MEMORY_MB;
2079 assert_eq!(
2080 res.can_compile, expected,
2081 "can_compile mismatch: disk={}MB mem={}MB",
2082 res.disk_available_mb, res.memory_total_mb
2083 );
2084 }
2085
2086 #[test]
2087 #[cfg(not(windows))]
2088 fn real_system_choose_method_respects_live_capabilities() {
2089 let sys = local_system_info();
2090 let res = local_resource_info();
2091
2092 let installer = RemoteInstaller::new("localhost", sys, res);
2093 let compile_safe = installer.can_compile().is_ok();
2094 let source_install_possible = compile_safe
2095 && ((installer.system_info.has_cargo_binstall
2096 && installer.prebuilt_binary_fast_path_is_safe())
2097 || installer.system_info.has_cargo
2098 || installer.system_info.has_curl);
2099 let method = installer.choose_method();
2100 let method_matches_live_capabilities = if source_install_possible {
2101 method.is_some()
2102 } else {
2103 method.is_none()
2104 || matches!(
2105 method,
2106 Some(InstallMethod::PrebuiltBinary {
2107 checksum: Some(_),
2108 ..
2109 })
2110 )
2111 };
2112
2113 assert!(
2114 method_matches_live_capabilities,
2115 "real system selected an unexpected install method: source_install_possible={source_install_possible}, method={method:?}"
2116 );
2117 }
2118
2119 #[test]
2120 #[ignore = "environment-dependent: requires >=2GB disk space"]
2121 fn real_system_check_resources_ok() {
2122 let sys = local_system_info();
2123 let res = local_resource_info();
2124 let installer = RemoteInstaller::new("localhost", sys, res);
2126 assert!(
2127 installer.check_resources().is_ok(),
2128 "dev machine should pass resource check"
2129 );
2130 }
2131
2132 #[test]
2133 #[ignore = "environment-dependent: requires >=2GB disk space and >=1GB memory"]
2134 fn real_system_can_compile_ok() {
2135 let sys = local_system_info();
2136 let res = local_resource_info();
2137 let installer = RemoteInstaller::new("localhost", sys, res);
2138 assert!(
2139 installer.can_compile().is_ok(),
2140 "dev machine should be able to compile"
2141 );
2142 }
2143
2144 #[test]
2145 fn real_system_prebuilt_url_valid() {
2146 let sys = local_system_info();
2147 let res = local_resource_info();
2148 let installer = RemoteInstaller::new("localhost", sys, res);
2149 if let Some(url) = installer.get_prebuilt_url() {
2150 assert!(url.starts_with("https://"), "URL should be https: {}", url);
2151 assert!(
2152 url.contains("linux") || url.contains("darwin"),
2153 "URL should contain OS: {}",
2154 url
2155 );
2156 }
2157 }
2159
2160 #[test]
2161 fn real_system_tool_detection_consistent() {
2162 let sys = local_system_info();
2163 if sys.has_cargo_binstall {
2165 assert!(sys.has_cargo, "binstall requires cargo");
2166 }
2167 assert!(
2169 sys.has_curl || sys.has_wget,
2170 "system should have at least one download tool"
2171 );
2172 }
2173}