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 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
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 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
1190fn 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 system.has_curl = false;
1308 system.has_wget = false;
1309 let resources = fixture_resources();
1310
1311 let installer = RemoteInstaller::new("test", system, resources);
1312 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 system.has_curl = true;
1361 system.has_wget = false;
1362 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 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 #[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 let valid = "a".repeat(64);
1782 assert_eq!(valid.len(), 64);
1783 assert!(valid.chars().all(|c| c.is_ascii_hexdigit()));
1784
1785 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 let short = "a".repeat(32);
1795 assert!(short.len() != 64);
1796
1797 let long = "a".repeat(128);
1799 assert!(long.len() != 64);
1800
1801 let invalid = "g".repeat(64); 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 let json = serde_json::to_string(&method).unwrap();
1877 assert!(json.contains("checksum"));
1878 assert!(json.contains(&"a".repeat(64)));
1879
1880 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 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, }
1963 }
1964
1965 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 .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 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 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 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 }
2134
2135 #[test]
2136 fn real_system_tool_detection_consistent() {
2137 let sys = local_system_info();
2138 if sys.has_cargo_binstall {
2140 assert!(sys.has_cargo, "binstall requires cargo");
2141 }
2142 assert!(
2144 sys.has_curl || sys.has_wget,
2145 "system should have at least one download tool"
2146 );
2147 }
2148}