use crate::device::{
DeviceCommandExecutionConfig, DeviceHandler, DeviceHandlerConfig, DeviceShellFlavor,
input_rule, prompt_rule, transition_rule,
};
use crate::error::ConnectError;
use std::collections::HashMap;
const LINUX_EXIT_CODE_MARKER: &str = "__RNETER_EXIT_CODE__:";
#[derive(Debug, Clone)]
pub struct LinuxTemplateConfig {
pub sudo_mode: SudoMode,
pub sudo_password: Option<String>,
pub custom_prompts: Option<CustomPrompts>,
pub shell_flavor: DeviceShellFlavor,
}
impl Default for LinuxTemplateConfig {
fn default() -> Self {
Self {
sudo_mode: SudoMode::SudoInteractive,
sudo_password: None,
custom_prompts: None,
shell_flavor: DeviceShellFlavor::Posix,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SudoMode {
SudoInteractive,
SudoShell,
Su,
DirectRoot,
}
#[derive(Debug, Clone)]
pub struct CustomPrompts {
pub user_prompts: Vec<&'static str>,
pub root_prompts: Vec<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinuxCommandType {
ReadOnly,
FileOp,
ServiceOp,
Custom,
}
pub fn classify_linux_command(command: &str) -> LinuxCommandType {
let cmd = command.trim().to_ascii_lowercase();
let readonly_prefixes = [
"ls",
"cat",
"grep",
"find",
"ps",
"top",
"df",
"du",
"free",
"uptime",
"systemctl status",
"journalctl",
"tail",
"head",
"less",
"more",
"which",
"whereis",
"pwd",
"whoami",
"id",
"uname",
"hostname",
];
if readonly_prefixes
.iter()
.any(|prefix| cmd.starts_with(prefix))
{
return LinuxCommandType::ReadOnly;
}
let service_prefixes = [
"systemctl start",
"systemctl stop",
"systemctl enable",
"systemctl disable",
"systemctl restart",
"service",
];
if service_prefixes
.iter()
.any(|prefix| cmd.starts_with(prefix))
{
return LinuxCommandType::ServiceOp;
}
let file_prefixes = ["echo", "sed", "awk", "rm", "mv", "cp", "touch", "mkdir"];
if file_prefixes.iter().any(|prefix| cmd.starts_with(prefix)) {
return LinuxCommandType::FileOp;
}
LinuxCommandType::Custom
}
pub fn linux() -> Result<DeviceHandler, ConnectError> {
linux_with_config(LinuxTemplateConfig::default())
}
pub fn linux_handler_config(config: LinuxTemplateConfig) -> DeviceHandlerConfig {
let (user_prompts, root_prompts) = if let Some(custom) = config.custom_prompts {
(custom.user_prompts, custom.root_prompts)
} else {
(
vec![
r"^[^\s]+\$\s*$", r"^[^\s]+@[^\s]+\$\s*$", r"^[^\s@]+@.+\$\s*$", r"^[^\s@]+@.+>\s*$", r"^\[[^\]]+\]\$\s*$", r"^\[[^\]]+\]\s+.+\$\s*$", r"^\[[^\]]+\]\s+.+>\s*$", r"^\$\s*$", ],
vec![
r"^[^\s]+#\s*$", r"^root@[^\s]+#\s*$", r"^[^\s@]+@.+#\s*$", r"^\[root@[^\]]+\]#\s*$", r"^\[[^\]]+\]\s+.+#\s*$", r"^\[[^\]]+\]#\s*$", r"^#\s*$", ],
)
};
let sudo_command = match config.sudo_mode {
SudoMode::SudoInteractive => "sudo -i",
SudoMode::SudoShell => "sudo -s",
SudoMode::Su => "su -",
SudoMode::DirectRoot => "",
};
let edges = if config.sudo_mode != SudoMode::DirectRoot {
vec![
transition_rule("User", sudo_command, "Root", false, false),
transition_rule("Root", "exit", "User", true, false),
]
} else {
vec![]
};
let mut dyn_param = HashMap::new();
if let Some(password) = config.sudo_password {
dyn_param.insert("SudoPassword".to_string(), password);
}
DeviceHandlerConfig {
prompt: vec![
prompt_rule("Root", &root_prompts),
prompt_rule("User", &user_prompts),
],
prompt_with_sys: Vec::new(),
prompt_prefix: Vec::new(),
write: vec![input_rule(
"SudoPassword",
true,
"SudoPassword",
false,
&[
r"\[sudo\] password for .+:\s*$",
r"Password:\s*$",
r"password:\s*$",
],
)],
more_regex: vec![
r"--More--".to_string(),
r"\(END\)".to_string(),
r"Press SPACE to continue".to_string(),
],
error_regex: vec![
r"^bash: .+: command not found".to_string(),
r"^-bash: .+: command not found".to_string(),
r"^sudo: .+: command not found".to_string(),
r"Permission denied".to_string(),
r"Operation not permitted".to_string(),
r"No such file or directory".to_string(),
r"cannot access".to_string(),
r"sudo: \d+ incorrect password attempt".to_string(),
r"su: Authentication failure".to_string(),
r"^E: .+".to_string(),
r"^Error: .+".to_string(),
r"^error: .+".to_string(),
r"^ERROR: .+".to_string(),
r"Failed to .+".to_string(),
r"fatal: .+".to_string(),
],
edges,
ignore_errors: Vec::new(),
dyn_param,
command_execution: DeviceCommandExecutionConfig::ShellExitStatus {
marker: LINUX_EXIT_CODE_MARKER.to_string(),
shell_flavor: config.shell_flavor,
},
}
}
pub fn linux_with_config(config: LinuxTemplateConfig) -> Result<DeviceHandler, ConnectError> {
linux_handler_config(config).build()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::RollbackPolicy;
use crate::templates::{
TemplateCapability, available_templates, build_tx_block, template_metadata,
};
#[test]
fn linux_template_has_user_and_root_states() {
let handler = linux().expect("create linux template");
let diagnostics = handler.diagnose_state_machine();
assert!(diagnostics.total_states >= 2);
assert_eq!(diagnostics.graph_states.len(), 2);
assert!(diagnostics.graph_states.contains(&"user".to_string()));
assert!(diagnostics.graph_states.contains(&"root".to_string()));
assert!(!diagnostics.has_issues());
}
#[test]
fn linux_template_is_in_builtin_templates() {
let names = available_templates();
assert!(names.contains(&"linux"));
}
#[test]
fn linux_template_metadata_is_correct() {
let meta = template_metadata("linux").expect("linux metadata");
assert_eq!(meta.name, "linux");
assert_eq!(meta.vendor, "Generic");
assert_eq!(meta.family, "Linux");
assert!(meta.capabilities.contains(&TemplateCapability::LoginMode));
assert!(meta.capabilities.contains(&TemplateCapability::EnableMode));
assert!(
meta.capabilities
.contains(&TemplateCapability::InteractiveInput)
);
}
#[test]
fn linux_template_by_name_works() {
let handler = crate::templates::by_name("linux").expect("linux template by name");
let diagnostics = handler.diagnose_state_machine();
assert!(diagnostics.total_states >= 2);
}
#[test]
fn linux_template_by_name_is_case_insensitive() {
let handler = crate::templates::by_name("LiNuX").expect("linux template case insensitive");
let diagnostics = handler.diagnose_state_machine();
assert!(!diagnostics.has_issues());
}
#[test]
fn linux_handler_config_rebuilds_equivalent_handler() {
let handler = linux().expect("linux template");
let rebuilt = linux_handler_config(LinuxTemplateConfig::default())
.build()
.expect("linux config");
assert!(handler.is_equivalent(&rebuilt));
}
#[test]
fn linux_handler_config_can_be_extended_by_callers() {
let mut config = linux_handler_config(LinuxTemplateConfig::default());
config
.prompt
.push(prompt_rule("Maintenance", &[r"^\[maint\]#\s*$"]));
let handler = config.build().expect("extended config");
assert!(
handler
.states()
.iter()
.any(|state| state.eq_ignore_ascii_case("Maintenance"))
);
}
#[test]
fn classify_linux_command_identifies_readonly() {
assert_eq!(classify_linux_command("ls -la"), LinuxCommandType::ReadOnly);
assert_eq!(
classify_linux_command("cat /etc/hosts"),
LinuxCommandType::ReadOnly
);
assert_eq!(
classify_linux_command("systemctl status nginx"),
LinuxCommandType::ReadOnly
);
assert_eq!(classify_linux_command("ps aux"), LinuxCommandType::ReadOnly);
}
#[test]
fn classify_linux_command_identifies_service_ops() {
assert_eq!(
classify_linux_command("systemctl start nginx"),
LinuxCommandType::ServiceOp
);
assert_eq!(
classify_linux_command("systemctl enable nginx"),
LinuxCommandType::ServiceOp
);
}
#[test]
fn classify_linux_command_identifies_file_ops() {
assert_eq!(
classify_linux_command("echo 'test' > /tmp/file"),
LinuxCommandType::FileOp
);
assert_eq!(
classify_linux_command("rm /tmp/file"),
LinuxCommandType::FileOp
);
}
#[test]
fn classify_linux_command_is_case_insensitive() {
assert_eq!(classify_linux_command("LS -LA"), LinuxCommandType::ReadOnly);
assert_eq!(
classify_linux_command("SYSTEMCTL START nginx"),
LinuxCommandType::ServiceOp
);
}
#[test]
fn linux_with_config_sudo_interactive() {
let config = LinuxTemplateConfig {
sudo_mode: SudoMode::SudoInteractive,
sudo_password: Some("test123".to_string()),
custom_prompts: None,
..LinuxTemplateConfig::default()
};
let handler = linux_with_config(config).expect("create linux with config");
let diagnostics = handler.diagnose_state_machine();
assert!(!diagnostics.has_issues());
}
#[test]
fn linux_with_config_sudo_shell() {
let config = LinuxTemplateConfig {
sudo_mode: SudoMode::SudoShell,
sudo_password: None,
custom_prompts: None,
..LinuxTemplateConfig::default()
};
let handler = linux_with_config(config).expect("create linux with sudo -s");
let diagnostics = handler.diagnose_state_machine();
assert!(!diagnostics.has_issues());
}
#[test]
fn linux_with_config_direct_root() {
let config = LinuxTemplateConfig {
sudo_mode: SudoMode::DirectRoot,
sudo_password: None,
custom_prompts: None,
..LinuxTemplateConfig::default()
};
let handler = linux_with_config(config).expect("create linux with direct root");
let diagnostics = handler.diagnose_state_machine();
assert_eq!(diagnostics.graph_states.len(), 0);
}
#[test]
fn linux_with_custom_prompts() {
let config = LinuxTemplateConfig {
sudo_mode: SudoMode::SudoInteractive,
sudo_password: None,
custom_prompts: Some(CustomPrompts {
user_prompts: vec![r"^myuser@myhost\$\s*$"],
root_prompts: vec![r"^root@myhost#\s*$"],
}),
..LinuxTemplateConfig::default()
};
let handler = linux_with_config(config).expect("create linux with custom prompts");
let diagnostics = handler.diagnose_state_machine();
assert!(!diagnostics.has_issues());
}
#[test]
fn build_tx_block_for_linux_readonly() {
let commands = vec!["ls -la".to_string(), "cat /etc/hosts".to_string()];
let tx = build_tx_block("linux", "show-block", "User", &commands, Some(30), None)
.expect("build show tx");
assert!(matches!(tx.rollback_policy, RollbackPolicy::None));
}
#[test]
fn build_tx_block_for_linux_mutating_commands_requires_explicit_rollback() {
let commands = vec!["apt install nginx".to_string()];
let result = build_tx_block("linux", "install-nginx", "Root", &commands, Some(60), None);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("mutating blocks require resource_rollback_command")
);
}
#[test]
fn build_tx_block_requires_explicit_rollback_for_mutating_commands() {
let commands = vec!["apt install nginx && rm -rf /".to_string()];
let result = build_tx_block("linux", "malicious", "Root", &commands, Some(60), None);
assert!(result.is_err(), "Should require explicit rollback command");
assert!(
result
.unwrap_err()
.to_string()
.contains("mutating blocks require resource_rollback_command")
);
}
#[test]
fn linux_template_password_not_recorded_in_output() {
let mut handler = linux().expect("create linux template");
handler
.dyn_param
.insert("SudoPassword".to_string(), "secret123".to_string());
assert!(handler.dyn_param.contains_key("SudoPassword"));
}
#[test]
fn linux_template_wraps_commands_for_exit_code_capture() {
let handler = linux().expect("create linux template");
let wrapped = handler.prepare_command_for_execution("false", true);
assert!(wrapped.starts_with("false; printf"));
assert!(wrapped.contains(LINUX_EXIT_CODE_MARKER));
assert!(wrapped.contains("\"$?\""));
}
#[test]
fn linux_template_can_force_fish_exit_status_capture() {
let handler = linux_with_config(LinuxTemplateConfig {
shell_flavor: DeviceShellFlavor::Fish,
..LinuxTemplateConfig::default()
})
.expect("create fish linux template");
let wrapped = handler.prepare_command_for_execution("date", true);
assert!(wrapped.contains(LINUX_EXIT_CODE_MARKER));
assert!(wrapped.contains("\"$status\""));
}
}