use super::ansi_c_decode::{Decoded, decode_ansi_c};
use super::architect_pane_env::{
ENV_RESET, RELATIVE_SOCKET, SERVER_ENV, assigns_dynamic_name, moves_server_env, resets_env,
};
use super::architect_pane_reach::{
dynamic_name, names_tmux, opaque_route, runner_texts, with_dynamics, without_data_bodies,
};
use super::architect_pane_verbs::{DENY_VERBS, Resolved, Verb, resolve};
use super::floor_d4::{program_positions, segments};
use super::shell_lex::QuoteScan;
use super::{command_substitutions, unclassifiable_command};
use crate::commands::hook_rewrite::is_env_assignment;
const MAX_DEPTH: usize = 3;
const MAX_SUFFIXES: usize = 64;
#[derive(Debug, Clone)]
pub(super) struct Word {
pub(super) text: String,
pub(super) dynamic: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Target {
Literal(String),
Dynamic(String),
Current,
Marked,
Server,
Picked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Hit {
pub(super) verb: String,
pub(super) server: Vec<String>,
pub(super) targets: Vec<Target>,
pub(super) opaque: Option<&'static str>,
pub(super) retargeted: bool,
pub(super) token: Option<String>,
}
impl Hit {
fn opaque(verb: &str, server: &[String], why: &'static str) -> Self {
Self {
verb: verb.to_string(),
server: server.to_vec(),
targets: Vec::new(),
opaque: Some(why),
retargeted: false,
token: None,
}
}
fn unparsed(text: &str) -> Self {
Self::named(UNPARSED, text)
}
fn named(why: &'static str, text: &str) -> Self {
let mut hit = Self::opaque("", &[], why);
hit.token = Some(text.chars().take(80).collect());
hit
}
}
pub(super) const UNPARSED: &str = "the command does not parse";
const DYNAMIC_PROGRAM: &str = "its program word is one the shell expands";
const UNKNOWN_COMMAND: &str = "an unknown command, possibly an alias";
#[derive(Debug, Clone)]
enum Context {
Caller,
Keys(Vec<Target>, Vec<String>),
Deferred(Vec<String>),
}
impl Context {
fn server(&self) -> &[String] {
match self {
Self::Caller => &[],
Self::Keys(_, server) | Self::Deferred(server) => server,
}
}
}
struct Scan {
hits: Vec<Hit>,
retargets: bool,
dynamic_env: bool,
context: Context,
}
pub(super) fn tmux_hits(command: &str) -> Vec<Hit> {
let command = &without_data_bodies(command);
let mut scan = Scan {
hits: Vec::new(),
retargets: false,
dynamic_env: false,
context: Context::Caller,
};
shell(command, 0, &mut scan);
let moved = scan.dynamic_env || moves_server_env(command);
for hit in &mut scan.hits {
if moved {
hit.opaque.get_or_insert(SERVER_ENV);
}
hit.retargeted = scan.retargets;
}
scan.hits
}
pub(super) fn may_run_tmux(text: &str, depth: usize) -> bool {
let words = || text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'));
words().any(|w| w.eq_ignore_ascii_case("tmux"))
&& (depth == 0 || words().any(|w| DENY_VERBS.iter().any(|v| v.name == w || v.alias == w)))
}
fn quote_escaped_semicolons(command: &str) -> String {
let scan = QuoteScan::new(command);
let bytes = command.as_bytes();
let mut out = String::with_capacity(command.len());
let mut i = 0;
while i < bytes.len() {
let escapes = bytes[..i].iter().rev().take_while(|b| **b == b'\\').count();
if bytes[i] == b'\\'
&& bytes.get(i + 1) == Some(&b';')
&& scan.is_unquoted(i)
&& escapes % 2 == 0
{
out.push_str("';'");
i += 2;
continue;
}
let len = command[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&command[i..i + len]);
i += len;
}
out
}
fn shell(command: &str, depth: usize, out: &mut Scan) {
let command = "e_escaped_semicolons(&without_data_bodies(command));
if unclassifiable_command(command).is_some() && (depth == 0 || may_run_tmux(command, depth)) {
match decode_ansi_c(command) {
Decoded::Text(text) if unclassifiable_command(&text).is_none() => {
shell(&text, depth, out);
}
Decoded::Undecodable(text) => out.hits.push(Hit::unparsed(&text)),
_ => out.hits.push(Hit::unparsed(command.trim())),
}
return;
}
for seg in segments(command) {
let Some(argv) = shlex::split(seg.text.trim()) else {
if may_run_tmux(&seg.text, depth) {
out.hits.push(Hit::unparsed(seg.text.trim()));
}
continue;
};
let words = with_dynamics(&seg.text, argv);
let texts: Vec<String> = words.iter().map(|w| w.text.clone()).collect();
for (pos, base) in program_positions(&texts) {
out.dynamic_env |= assigns_dynamic_name(&texts[pos..]);
let dynamic = dynamic_name(&words[pos]) && !is_env_assignment(&texts[pos]);
if dynamic && (names_tmux(command) || reads_as_tmux(&words[pos + 1..], out)) {
out.hits.push(Hit::named(DYNAMIC_PROGRAM, &texts[pos]));
}
if let Some(why) = opaque_route(&texts, pos, seg.piped, command) {
out.hits.push(Hit::named(why, &texts[pos]));
}
if depth < MAX_DEPTH {
for text in runner_texts(&texts, pos) {
shell(&text, depth + 1, out);
}
}
if base.eq_ignore_ascii_case("tmux") {
let reset = resets_env(&texts[..pos]);
invocation(&words[pos + 1..], depth, reset, out);
}
}
}
if depth < MAX_DEPTH {
for body in command_substitutions(command) {
shell(body.text(), depth + 1, out);
}
}
}
fn reads_as_tmux(args: &[Word], out: &Scan) -> bool {
let mut probe = Scan {
hits: Vec::new(),
retargets: false,
dynamic_env: false,
context: out.context.clone(),
};
options_then_commands(args, MAX_DEPTH, &mut probe);
probe
.hits
.iter()
.any(|h| DENY_VERBS.iter().any(|v| v.name == h.verb))
}
fn invocation(words: &[Word], depth: usize, reset: bool, out: &mut Scan) {
let start = out.hits.len();
let unknown = options_then_commands(words, depth, out);
if let Some(why) = reset.then_some(ENV_RESET).or(unknown) {
for hit in &mut out.hits[start..] {
hit.opaque.get_or_insert(why);
}
}
}
fn options_then_commands(words: &[Word], depth: usize, out: &mut Scan) -> Option<&'static str> {
let mut server: Vec<String> = out.context.server().to_vec();
let mut own_server = false;
let mut unknown = None;
let mut i = 0;
while let Some(word) = words.get(i) {
let text = word.text.as_str();
if text == "--" {
i += 1;
break;
}
if !text.starts_with('-') || text == "-" {
break;
}
if word.dynamic {
out.hits
.push(Hit::opaque("", &server, "an option the shell expands"));
return None;
}
for (k, c) in text.char_indices().skip(1) {
if c == 'C' {
out.hits.push(Hit::opaque(
"-C",
&server,
"control mode reads its commands from stdin",
));
return None;
}
if !"cfLST".contains(c) {
continue;
}
let inline = &text[k + 1..];
let value = if inline.is_empty() {
i += 1;
words.get(i).cloned()
} else {
Some(Word {
text: inline.to_string(),
dynamic: false,
})
};
let Some(value) = value else {
out.hits
.push(Hit::opaque("", &server, "an option has no value"));
return None;
};
if matches!(c, 'L' | 'S') {
if value.dynamic {
out.hits
.push(Hit::opaque("", &server, "a server the shell expands"));
return None;
}
if c == 'S' && !value.text.starts_with('/') {
unknown = Some(RELATIVE_SOCKET);
}
if !std::mem::replace(&mut own_server, true) {
server.clear();
}
server.extend([format!("-{c}"), value.text]);
} else if c == 'c' && depth < MAX_DEPTH {
shell(&value.text, depth + 1, out);
}
break;
}
i += 1;
}
commands(&words[i..], &server, depth, false, out);
if !own_server && !server.is_empty() && matches!(out.context, Context::Keys(..)) {
commands(&words[i..], &[], depth, false, out);
}
unknown
}
fn commands(words: &[Word], server: &[String], depth: usize, nested: bool, out: &mut Scan) {
let mut argv: Vec<Word> = Vec::new();
for word in words {
let text = word.text.as_str();
if let Some(head) = text.strip_suffix(';').filter(|_| !text.ends_with("\\;")) {
if !head.is_empty() {
argv.push(Word {
text: head.to_string(),
dynamic: word.dynamic,
});
}
command(&std::mem::take(&mut argv), server, depth, nested, out);
} else {
argv.push(word.clone());
}
}
command(&argv, server, depth, nested, out);
}
fn command(argv: &[Word], server: &[String], depth: usize, nested: bool, out: &mut Scan) {
let Some((head, args)) = argv.split_first() else {
return;
};
if head.dynamic {
if !nested {
out.hits
.push(Hit::opaque("", server, "a command the shell expands"));
}
return;
}
if retargets(&head.text, args, &out.context) {
out.retargets = true;
}
let mut inner = Context::Deferred(server.to_vec());
match resolve(&head.text, nested) {
Resolved::Unknown if nested => {
let aliased = head.text.split_once('=').map(|(_, v)| resolve(v, false));
if matches!(aliased, Some(Resolved::Deny(_) | Resolved::Opaque)) {
out.hits
.push(Hit::opaque(&head.text, server, "an alias for a deny verb"));
}
return;
}
Resolved::Unknown => {
out.hits
.push(Hit::opaque(&head.text, server, UNKNOWN_COMMAND));
return;
}
Resolved::Opaque => out.hits.push(Hit::opaque(
&head.text,
server,
"it runs commands the guard cannot read",
)),
Resolved::Deny(verb) => {
let found = deny_targets(verb, args)
.and_then(|t| t.map(|t| in_context(t, &out.context, server)).transpose());
match found {
Ok(Some(targets)) => {
if verb.name == "send-keys" {
inner = Context::Keys(targets.clone(), server.to_vec());
}
out.hits.push(Hit {
verb: verb.name.to_string(),
server: server.to_vec(),
targets,
opaque: None,
retargeted: false,
token: None,
});
}
Ok(None) => {}
Err(why) => out.hits.push(Hit::opaque(verb.name, server, why)),
}
}
Resolved::Known => {}
}
if depth >= MAX_DEPTH {
return;
}
let outer = std::mem::replace(&mut out.context, inner);
arguments(args, server, depth, out);
out.context = outer;
}
fn arguments(args: &[Word], server: &[String], depth: usize, out: &mut Scan) {
for word in args.iter().filter(|w| !w.dynamic) {
shell(&word.text, depth + 1, out);
if let Some(inner) = shlex::split(&word.text) {
let inner: Vec<Word> = inner
.into_iter()
.map(|text| Word {
dynamic: text.contains(['$', '`']) || text.contains("#{"),
text,
})
.collect();
commands(&inner, server, depth + 1, true, out);
}
}
for start in 1..args.len().min(MAX_SUFFIXES) {
commands(&args[start..], server, depth + 1, true, out);
}
}
fn retargets(head: &str, args: &[Word], context: &Context) -> bool {
let named = |full: &str, alias: &str, unique: usize| {
head == alias || (head.len() >= unique && full.starts_with(head))
};
let verb = named("rename-session", "rename", 8) || named("new-session", "new", 5);
verb && (!matches!(context, Context::Keys(..)) || args.iter().any(|w| w.text.starts_with('-')))
}
fn in_context(
targets: Vec<Target>,
context: &Context,
server: &[String],
) -> Result<Vec<Target>, &'static str> {
let defaulted = |t: &Target| matches!(t, Target::Current | Target::Marked);
match context {
Context::Caller => Ok(targets),
Context::Deferred(_) if targets.iter().any(defaulted) => {
Err("a nested command with no target acts on a pane tmux picks when it runs")
}
Context::Deferred(_) => Ok(targets),
Context::Keys(typed_into, keys) => Ok(targets
.into_iter()
.flat_map(|t| match t {
Target::Current if keys.as_slice() != server => vec![Target::Picked],
Target::Current => typed_into.clone(),
t => vec![t],
})
.collect()),
}
}
fn deny_targets(verb: &Verb, args: &[Word]) -> Result<Option<Vec<Target>>, &'static str> {
let flags = parse_flags(verb.opts, args)?;
let has = |c: char| flags.iter().any(|(f, _)| *f == c);
let values = |c: char| -> Vec<Target> {
flags
.iter()
.filter(|(f, _)| *f == c)
.filter_map(|(_, v)| v.as_ref())
.map(|w| {
if w.dynamic {
Target::Dynamic(w.text.clone())
} else {
Target::Literal(w.text.clone())
}
})
.collect()
};
if verb.name == "new-window" && !has('k') {
return Ok(None);
}
if verb.name == "send-keys" && has('c') {
return Err("`-c` sends keys through a client whose pane is unknown");
}
let mut targets = values('t');
if verb.name == "kill-server" || (verb.name == "kill-session" && has('a')) {
targets.insert(0, Target::Server);
return Ok(Some(targets));
}
if targets.is_empty() {
targets.push(Target::Current);
}
if verb.src {
let sources = values('s');
if sources.is_empty() {
targets.extend([Target::Current, Target::Marked]);
}
targets.extend(sources);
}
Ok(Some(targets))
}
fn parse_flags(opts: &str, args: &[Word]) -> Result<Vec<(char, Option<Word>)>, &'static str> {
let mut out = Vec::new();
let mut i = 0;
while let Some(word) = args.get(i) {
let text = word.text.as_str();
if text == "--" {
break;
}
if word.dynamic {
return Err("a word the shell expands before `--` ends the options");
}
if !text.starts_with('-') || text == "-" {
break;
}
for (k, c) in text.char_indices().skip(1) {
let at = opts
.find(c)
.filter(|_| c != ':')
.ok_or("an option this guard does not know")?;
if opts[at + 1..].starts_with(':') {
let inline = &text[k + 1..];
let value = if inline.is_empty() {
i += 1;
args.get(i).cloned().ok_or("an option has no value")?
} else {
Word {
text: inline.to_string(),
dynamic: false,
}
};
out.push((c, Some(value)));
break;
}
out.push((c, None));
}
i += 1;
}
Ok(out)
}