pub mod edit;
pub mod port_forward;
pub mod shell;
use std::{
collections::BTreeSet,
io,
process::{Output, Stdio},
sync::Arc,
};
use anyhow::anyhow;
use log::{error, info};
use regex::Regex;
use serde_json::Value as JValue;
use tokio::sync::Mutex;
use crate::{
app::{self, models::ScrollableTxt, App, Cli},
config::{CliInfoConfig, CliInfoEntry},
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IoCmdEvent {
GetCliInfo,
GetDescribe {
kind: String,
value: String,
ns: Option<String>,
},
}
#[derive(Clone)]
pub struct CmdRunner<'a> {
pub app: &'a Arc<Mutex<App>>,
}
static NOT_FOUND: &str = "Not found";
#[derive(Debug, Eq, PartialEq)]
enum CliProbe {
MissingBinary,
Version(Option<String>),
}
pub(crate) fn is_valid_kubectl_arg(s: &str) -> bool {
!s.contains('\n')
&& !s.contains('\r')
&& !s.contains('\0')
&& !s.contains(';')
&& !s.contains('|')
&& !s.contains('&')
&& !s.contains('`')
&& !s.contains('$')
}
pub(crate) fn push_context_arg(args: &mut Vec<String>, context: Option<&str>) {
if let Some(context) = context {
args.push("--context".into());
args.push(context.into());
}
}
const VERSION_REGEX: &str = r"\b(v[0-9]+\.[0-9]+\.[0-9]+)\b";
const REGEX_PROBES: [(&str, &[&[&str]], &str); 6] = [
(
"docker",
&[&["docker", "version", "--format", "v{{.Client.Version}}"]],
VERSION_REGEX,
),
(
"docker-compose",
&[
&["docker-compose", "version"],
&["docker", "compose", "version"],
],
r"\b(v?[0-9]+\.[0-9]+\.[0-9]+)\b",
),
(
"podman",
&[&["podman", "version", "--format", "v{{.Client.Version}}"]],
VERSION_REGEX,
),
("containerd", &[&["containerd", "--version"]], VERSION_REGEX),
("helm", &[&["helm", "version", "--short"]], VERSION_REGEX),
("kind", &[&["kind", "version"]], VERSION_REGEX),
];
impl<'a> CmdRunner<'a> {
pub fn new(app: &'a Arc<Mutex<App>>) -> Self {
CmdRunner { app }
}
pub async fn handle_cmd_event(&mut self, io_event: IoCmdEvent) {
match io_event {
IoCmdEvent::GetCliInfo => {
self.get_cli_info().await;
}
IoCmdEvent::GetDescribe { kind, value, ns } => {
self.get_describe(kind, value, ns).await;
}
};
let mut app = self.app.lock().await;
app.loading_complete();
}
async fn handle_error(&self, e: anyhow::Error) {
error!("{:?}", e);
let mut app = self.app.lock().await;
app.handle_error(e);
}
async fn get_cli_info(&self) {
let cli_info_config = {
let app = self.app.lock().await;
app.config.cli_info.clone().unwrap_or_default()
};
let mut clis: Vec<Cli> = vec![];
let mut cli_index = 0u8;
{
let disabled_defaults = disabled_default_set(&cli_info_config);
let hide_missing_binaries = cli_info_config.hide_missing_binaries;
if !disabled_defaults.contains("kubectl client")
|| !disabled_defaults.contains("kubectl server")
{
let kubectl_probe = match run_cmd("kubectl", &["version", "-o", "json"]).await {
Ok(out) => {
if out.status.success() {
let out = String::from_utf8_lossy(&out.stdout);
info!("kubectl version: {}", out);
let v: serde_json::Result<JValue> = serde_json::from_str(&out);
match v {
Ok(val) => CliProbe::Version(Some(format!(
"{}|{}",
val["clientVersion"]["gitVersion"]
.to_string()
.replace('"', ""),
val["serverVersion"]["gitVersion"]
.to_string()
.replace('"', ""),
))),
_ => CliProbe::Version(None),
}
} else {
let err = String::from_utf8_lossy(&out.stderr);
error!("kubectl version failed: {}", err);
CliProbe::Version(None)
}
}
Err(_) => CliProbe::Version(None),
};
let versions = match kubectl_probe {
CliProbe::Version(versions) => versions
.as_deref()
.and_then(|joined| joined.split_once('|'))
.map(|(client, server)| (Some(client.to_string()), Some(server.to_string()))),
CliProbe::MissingBinary if hide_missing_binaries => None,
CliProbe::MissingBinary => Some((None, None)),
};
if let Some((version_c, version_s)) = versions {
if !disabled_defaults.contains("kubectl client") {
clis.push(build_cli("kubectl client", version_c, cli_index));
cli_index += 1;
}
if !disabled_defaults.contains("kubectl server") {
clis.push(build_cli("kubectl server", version_s, cli_index));
cli_index += 1;
}
}
}
let mut join_set = tokio::task::JoinSet::new();
for (label, commands, regex) in REGEX_PROBES
.iter()
.filter(|(label, _, _)| !disabled_defaults.contains(*label))
{
let label = *label;
let commands = *commands;
let regex = Regex::new(regex).unwrap();
let index = cli_index;
join_set.spawn(async move {
build_cli_for_probe(
label,
run_default_probe(commands, ®ex).await,
hide_missing_binaries,
index,
)
});
cli_index += 1;
}
for entry in &cli_info_config.custom {
if entry.command.is_empty() {
continue;
}
let entry = entry.clone();
join_set.spawn(async move {
build_cli_for_probe(
&entry.label,
run_cli_entry(&entry).await,
hide_missing_binaries,
cli_index,
)
});
cli_index += 1;
}
for cli in join_set.join_all().await.into_iter().flatten() {
clis.push(cli);
}
clis.sort_unstable_by_key(|cli| cli.index);
}
let mut app = self.app.lock().await;
app.data.clis = clis;
}
async fn get_describe(&self, kind: String, value: String, ns: Option<String>) {
if !is_valid_kubectl_arg(&kind) || !is_valid_kubectl_arg(&value) {
self
.handle_error(anyhow!("Invalid characters in resource kind or name"))
.await;
return;
}
if let Some(ref ns) = ns {
if !is_valid_kubectl_arg(ns) {
self
.handle_error(anyhow!("Invalid characters in namespace"))
.await;
return;
}
}
let context = {
let app = self.app.lock().await;
app.data.selected.context.clone()
};
if let Some(ref context) = context {
if !is_valid_kubectl_arg(context) {
self
.handle_error(anyhow!("Invalid characters in context"))
.await;
return;
}
}
let kind_clone = kind.clone();
let mut args = vec!["describe".to_string(), kind, value];
if let Some(ns) = ns {
args.push("-n".to_string());
args.push(ns);
}
push_context_arg(&mut args, context.as_deref());
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
match run_cmd("kubectl", &arg_refs).await {
Ok(output) if output.status.success() => {
let out = String::from_utf8_lossy(&output.stdout).to_string();
let mut app = self.app.lock().await;
app.data.describe_out = ScrollableTxt::with_string(out);
}
Ok(output) => {
self
.handle_error(anyhow!(
"Error running {} describe: {}",
kind_clone,
String::from_utf8_lossy(&output.stderr).trim()
))
.await
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
self
.handle_error(anyhow!(
"Error running {} describe. Make sure you have kubectl installed: {:?}",
kind_clone,
e
))
.await
}
Err(e) => {
self
.handle_error(anyhow!("Error running {} describe: {:?}", kind_clone, e))
.await
}
}
}
}
fn build_cli(name: &str, version: Option<String>, index: u8) -> app::Cli {
app::Cli {
name: name.to_owned(),
status: version.is_some(),
version: version.unwrap_or_else(|| NOT_FOUND.into()),
index,
}
}
fn build_cli_for_probe(
name: &str,
probe: CliProbe,
hide_missing_binaries: bool,
index: u8,
) -> Option<app::Cli> {
match probe {
CliProbe::MissingBinary if hide_missing_binaries => None,
CliProbe::MissingBinary => Some(build_cli(name, None, index)),
CliProbe::Version(version) => Some(build_cli(name, version, index)),
}
}
fn disabled_default_set(cli_info: &CliInfoConfig) -> BTreeSet<String> {
cli_info
.disable_defaults
.iter()
.map(|label| label.trim().to_lowercase())
.collect()
}
async fn run_default_probe(commands: &[&[&str]], regex: &Regex) -> CliProbe {
let mut saw_binary = false;
for command in commands {
let entry = CliInfoEntry {
label: String::new(),
command: command.iter().map(|s| s.to_string()).collect(),
regex: Some(regex.clone()),
};
match run_cli_entry(&entry).await {
CliProbe::Version(Some(version)) => {
return CliProbe::Version(Some(normalize_version_prefix(version)))
}
CliProbe::Version(None) => saw_binary = true,
CliProbe::MissingBinary => {}
}
}
if saw_binary {
CliProbe::Version(None)
} else {
CliProbe::MissingBinary
}
}
fn normalize_version_prefix(version: String) -> String {
if version.starts_with('v') {
version
} else {
format!("v{version}")
}
}
async fn run_cli_entry(entry: &CliInfoEntry) -> CliProbe {
let Some((program, args)) = entry.command.split_first() else {
return CliProbe::Version(None);
};
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
match run_cmd(program, &arg_refs).await {
Ok(result) if result.status.success() => {
let value = String::from_utf8_lossy(&result.stdout);
if let Some(ref re) = entry.regex {
if let Some(re_match) = re
.captures(&value)
.and_then(|cap| cap.get(1))
.map(|matched| matched.as_str().trim().to_string())
{
return CliProbe::Version(Some(re_match));
}
} else {
return CliProbe::Version(Some(value.trim().to_string()));
}
}
Ok(result) => {
error!(
"Command {:?} failed with status {:?}: {}",
entry.command,
result.status,
String::from_utf8_lossy(&result.stderr)
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return CliProbe::MissingBinary;
}
Err(e) => {
error!("Command {:?} failed: {:?}", entry.command, e);
}
};
CliProbe::Version(None)
}
async fn run_cmd(cmd: &str, args: &[&str]) -> Result<Output, io::Error> {
tokio::process::Command::new(cmd)
.args(args)
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?
.wait_with_output()
.await
}
#[cfg(test)]
mod tests {
use regex::Regex;
use super::CliProbe;
use crate::config::{CliInfoConfig, CliInfoEntry};
#[test]
fn test_is_valid_arg_accepts_normal_input() {
assert!(super::is_valid_kubectl_arg("pod"));
assert!(super::is_valid_kubectl_arg("my-deployment"));
assert!(super::is_valid_kubectl_arg("my_namespace"));
assert!(super::is_valid_kubectl_arg("kube-system"));
assert!(super::is_valid_kubectl_arg(
"nginx-ingress-controller-abc123"
));
assert!(super::is_valid_kubectl_arg("default"));
assert!(super::is_valid_kubectl_arg("my.resource.name"));
}
#[test]
fn test_is_valid_arg_rejects_injection_attempts() {
assert!(!super::is_valid_kubectl_arg("pod; rm -rf /"));
assert!(!super::is_valid_kubectl_arg("pod | cat /etc/passwd"));
assert!(!super::is_valid_kubectl_arg("pod & malicious-cmd"));
assert!(!super::is_valid_kubectl_arg("pod `whoami`"));
assert!(!super::is_valid_kubectl_arg("pod\nmalicious"));
assert!(!super::is_valid_kubectl_arg("pod\rmalicious"));
assert!(!super::is_valid_kubectl_arg("pod\0malicious"));
assert!(!super::is_valid_kubectl_arg("$HOME"));
assert!(!super::is_valid_kubectl_arg("$(whoami)"));
}
#[test]
fn test_is_valid_arg_empty_string() {
assert!(super::is_valid_kubectl_arg(""));
}
#[test]
fn test_disabled_default_set_normalizes_labels() {
let set = super::disabled_default_set(&CliInfoConfig {
hide_missing_binaries: true,
disable_defaults: vec![" Docker ".into(), "kubectl client".into()],
custom: vec![],
});
assert!(set.contains("docker"));
assert!(set.contains("kubectl client"));
}
fn sync_probe(entry: &CliInfoEntry) -> CliProbe {
tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
.unwrap()
.block_on(super::run_cli_entry(entry))
}
#[test]
fn test_run_custom_cli_command_uses_first_non_empty_line() {
let entry = CliInfoEntry {
label: "rustc".into(),
command: vec!["rustc".into(), "--version".into()],
regex: None,
};
assert!(matches!(
sync_probe(&entry),
CliProbe::Version(Some(version)) if version.starts_with("rustc ")
));
}
#[test]
fn test_run_custom_cli_command_returns_none_for_empty_command() {
let entry = CliInfoEntry {
label: "broken".into(),
command: vec![],
regex: None,
};
assert_eq!(sync_probe(&entry), CliProbe::Version(None));
}
#[test]
fn test_run_custom_cli_command_uses_regex_when_configured() {
let entry = CliInfoEntry {
label: "echo".into(),
command: vec!["echo".into(), "release=1.2.3".into()],
regex: Some(Regex::new(r"release=([0-9.]+)").unwrap()),
};
assert_eq!(sync_probe(&entry), CliProbe::Version(Some("1.2.3".into())));
}
#[test]
fn test_run_custom_cli_command_returns_none_when_regex_does_not_match() {
let entry = CliInfoEntry {
label: "echo".into(),
command: vec!["echo".into(), "release=1.2.3".into()],
regex: Some(Regex::new(r"version=([0-9.]+)").unwrap()),
};
assert_eq!(sync_probe(&entry), CliProbe::Version(None));
}
#[test]
fn test_run_cli_entries_tries_fallback_commands() {
let entries = [
CliInfoEntry {
label: String::new(),
command: vec!["definitely-not-installed-kdash".into()],
regex: None,
},
CliInfoEntry {
label: String::new(),
command: vec!["echo".into(), "release=1.2.3".into()],
regex: Some(Regex::new(r"release=([0-9.]+)").unwrap()),
},
];
assert_eq!(
sync_probe(&entries[1]),
CliProbe::Version(Some("1.2.3".into()))
);
}
#[test]
fn test_build_cli_for_probe_hides_missing_binary_by_default() {
assert!(super::build_cli_for_probe("docker", CliProbe::MissingBinary, true, 0u8).is_none());
}
#[test]
fn test_build_cli_for_probe_can_show_missing_binary() {
let cli = super::build_cli_for_probe("docker", CliProbe::MissingBinary, false, 0u8)
.expect("missing binary should still render");
assert_eq!(cli.name, "docker");
assert_eq!(cli.version, "Not found");
assert!(!cli.status);
}
}