pub mod config;
pub mod index;
pub mod install;
pub mod interactive;
pub mod probe;
pub mod provenance;
pub mod setup;
pub mod sync;
use std::io::Read as IoRead;
use std::process::{Child, Output};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
pub(crate) const HOST_KEY_VERIFICATION_FAILED: &str = "Host key verification failed";
pub(crate) fn strict_ssh_cli_tokens(connect_timeout_secs: u64) -> Vec<String> {
let mut tokens = Vec::new();
if let Some(config_path) = ssh_config_override() {
tokens.push("-F".to_string());
tokens.push(config_path);
}
tokens.extend([
"-o".to_string(),
"BatchMode=yes".to_string(),
"-o".to_string(),
format!("ConnectTimeout={connect_timeout_secs}"),
"-o".to_string(),
"ServerAliveInterval=15".to_string(),
"-o".to_string(),
"ServerAliveCountMax=3".to_string(),
"-o".to_string(),
"StrictHostKeyChecking=yes".to_string(),
]);
tokens
}
pub(crate) fn strict_ssh_command_for_rsync(connect_timeout_secs: u64) -> String {
let config_arg = ssh_config_override()
.map(|path| format!(" -F {}", shell_quote_ssh_arg(&path)))
.unwrap_or_default();
format!(
"ssh{config_arg} -o BatchMode=yes -o ConnectTimeout={connect_timeout_secs} -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o StrictHostKeyChecking=yes"
)
}
fn ssh_config_override() -> Option<String> {
dotenvy::var("CASS_SSH_CONFIG")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn shell_quote_ssh_arg(value: &str) -> String {
if !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | ':' | '@'))
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn drain_child_pipe<R>(mut pipe: R) -> Receiver<std::io::Result<Vec<u8>>>
where
R: IoRead + Send + 'static,
{
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut output = Vec::new();
let result = pipe.read_to_end(&mut output).map(|_| output);
let _ = sender.send(result);
});
receiver
}
fn finish_child_pipe(
pipe_reader: Option<Receiver<std::io::Result<Vec<u8>>>>,
deadline: Instant,
) -> std::io::Result<Option<Vec<u8>>> {
match pipe_reader {
Some(reader) => {
let remaining = deadline
.checked_duration_since(Instant::now())
.unwrap_or(Duration::ZERO);
match reader.recv_timeout(remaining) {
Ok(result) => result.map(Some),
Err(RecvTimeoutError::Timeout) => Ok(None),
Err(RecvTimeoutError::Disconnected) => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"child pipe reader disconnected before sending output",
)),
}
}
None => Ok(Some(Vec::new())),
}
}
pub(crate) fn wait_for_child_output_with_timeout(
mut child: Child,
timeout: Duration,
) -> std::io::Result<Option<Output>> {
let timeout = if timeout.is_zero() {
Duration::from_secs(1)
} else {
timeout
};
let start = Instant::now();
let deadline = start.checked_add(timeout).unwrap_or(start);
let stdout_reader = child.stdout.take().map(drain_child_pipe);
let stderr_reader = child.stderr.take().map(drain_child_pipe);
match child.wait_timeout(timeout)? {
Some(status) => {
let Some(stdout) = finish_child_pipe(stdout_reader, deadline)? else {
return Ok(None);
};
let Some(stderr) = finish_child_pipe(stderr_reader, deadline)? else {
return Ok(None);
};
Ok(Some(Output {
status,
stdout,
stderr,
}))
}
None => {
let _ = child.kill();
let _ = child.wait();
Ok(None)
}
}
}
pub(crate) fn is_host_key_verification_failure(stderr: &str) -> bool {
stderr.contains(HOST_KEY_VERIFICATION_FAILED)
}
pub(crate) fn host_key_verification_error(host: &str) -> String {
format!(
"Host key verification failed for {host} (add/verify host key in ~/.ssh/known_hosts first)"
)
}
pub use config::{
BackupInfo, ConfigError, ConfigPreview, DiscoveredHost, MergeResult, PathMapping, Platform,
SkipReason, SourceConfigGenerator, SourceDefinition, SourcesConfig, SyncSchedule,
discover_ssh_hosts, get_preset_paths,
};
pub use provenance::{LOCAL_SOURCE_ID, Origin, Source, SourceFilter, SourceKind};
pub use sync::{
PathSyncResult, SourceHealthKind, SourceSyncAction, SourceSyncDecision, SourceSyncInfo,
SyncEngine, SyncError, SyncMethod, SyncReport, SyncResult, SyncStatus,
};
pub use probe::{
CassStatus, DetectedAgent, HostProbeResult, ProbeCache, ResourceInfo, SystemInfo, probe_host,
probe_hosts_parallel,
};
pub use install::{
InstallError, InstallMethod, InstallProgress, InstallResult, InstallStage, RemoteInstaller,
};
pub use index::{IndexError, IndexProgress, IndexResult, IndexStage, RemoteIndexer};
pub use interactive::{
CassStatusDisplay, HostDisplayInfo, HostSelectionResult, HostSelector, HostState,
InteractiveError, confirm_action, confirm_with_details, probe_to_display_info,
run_host_selection,
};
pub use setup::{SetupError, SetupOptions, SetupResult, SetupState, run_setup};
#[cfg(test)]
mod tests {
use super::*;
struct EnvGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = dotenvy::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.previous {
unsafe {
std::env::set_var(self.key, value);
}
} else {
unsafe {
std::env::remove_var(self.key);
}
}
}
}
#[test]
#[serial_test::serial]
fn strict_ssh_cli_tokens_include_config_override() {
let _guard = EnvGuard::set("CASS_SSH_CONFIG", "/tmp/cass ssh/config");
let tokens = strict_ssh_cli_tokens(5);
assert_eq!(tokens[0], "-F");
assert_eq!(tokens[1], "/tmp/cass ssh/config");
assert!(tokens.contains(&"StrictHostKeyChecking=yes".to_string()));
}
#[test]
#[serial_test::serial]
fn strict_ssh_command_for_rsync_quotes_config_override() {
let _guard = EnvGuard::set("CASS_SSH_CONFIG", "/tmp/cass'ssh/config");
let command = strict_ssh_command_for_rsync(5);
assert!(command.starts_with("ssh -F '/tmp/cass'\\''ssh/config' "));
assert!(command.contains("StrictHostKeyChecking=yes"));
}
#[test]
#[serial_test::serial]
fn strict_ssh_helpers_ignore_empty_config_override() {
let _guard = EnvGuard::set("CASS_SSH_CONFIG", " ");
let tokens = strict_ssh_cli_tokens(5);
let command = strict_ssh_command_for_rsync(5);
assert!(!tokens.contains(&"-F".to_string()));
assert!(!command.contains(" -F "));
}
}