pub(super) const SERVER_ENV: &str = "the command sets or unsets `TMUX` or `TMUX_TMPDIR`, so its \
tmux server may not be the one the guard lists";
pub(super) const ENV_RESET: &str = "`sudo`, `doas`, `env -i` or `exec -c` changes the \
environment tmux finds its server in";
pub(super) const RELATIVE_SOCKET: &str = "a relative `-S` socket path resolves against a \
directory the guard does not know";
pub(super) const PICKED: &str = "a nested command with no target, on another server than the \
keys go to, acts on a pane tmux picks";
pub(super) const RETARGET: &str = "the command also renames or creates a session, and targets \
resolve against the sessions that exist before it runs";
pub(super) fn moves_server_env(command: &str) -> bool {
let unquoted: String = command.chars().filter(|c| !"'\"\\".contains(*c)).collect();
names_server_env(command) || names_server_env(&unquoted) || assigns_unread_name(command)
}
fn names_server_env(command: &str) -> bool {
command.match_indices("TMUX").any(|(at, _)| {
let after = &command[at + 4..];
let after = after.strip_prefix("_TMPDIR").unwrap_or(after);
let whole = !after.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_');
let before = &command[..at];
let expanded = before.trim_end_matches(['{', '#', '!']).ends_with('$');
let assigns = before.ends_with("${") && starts_assigning(after);
whole && (!expanded || assigns)
})
}
fn starts_assigning(op: &str) -> bool {
["=", ":=", "::="].iter().any(|a| op.starts_with(a))
}
fn assigns_unread_name(command: &str) -> bool {
if !command.contains("${") {
return false;
}
let bytes = command.as_bytes();
let n = bytes.len();
let mut close: Vec<Option<usize>> = vec![None; n];
let mut open = Vec::new();
let (mut eq, mut p) = (vec![0usize; n + 1], vec![0usize; n + 1]);
for (i, b) in bytes.iter().enumerate() {
match b {
b'{' => open.push(i),
b'}' => {
if let Some(o) = open.pop() {
close[o] = Some(i);
}
}
_ => {}
}
eq[i + 1] = eq[i] + usize::from(*b == b'=');
p[i + 1] = p[i] + usize::from(*b == b'P');
}
let mut next_paren = vec![n; n + 1];
for i in (0..n).rev() {
next_paren[i] = if bytes[i] == b')' {
i
} else {
next_paren[i + 1]
};
}
command.match_indices("${").any(|(at, _)| {
let Some(end) = close[at + 1] else {
return true;
};
let start = at + 2;
let (flags, name) = if bytes.get(start) == Some(&b'(') {
let shut = next_paren[start + 1].min(end);
(start + 1..shut, (shut + 1).min(end))
} else {
(start..start, start)
};
let unread =
p[flags.end] > p[flags.start] || (name < end && matches!(bytes[name], b'!' | b'$'));
unread && eq[end] > eq[start]
})
}
pub(super) fn assigns_dynamic_name(argv: &[String]) -> bool {
let Some((program, args)) = argv.split_first() else {
return false;
};
let dynamic = |w: &String| w.contains(['$', '`']);
let name = |w: &String| w.split('=').next().unwrap_or_default().to_owned();
match program.as_str() {
"export" | "declare" | "typeset" | "local" | "readonly" | "integer" | "float" | "unset"
| "read" | "let" | "getopts" => args
.iter()
.filter(|w| !w.starts_with('-'))
.any(|w| dynamic(&name(w))),
"eval" => args.iter().any(dynamic),
"printf" => args
.windows(2)
.any(|pair| pair[0] == "-v" && dynamic(&pair[1])),
_ => false,
}
}
pub(super) fn resets_env(before: &[String]) -> bool {
let (mut in_env, mut in_exec) = (false, false);
for word in before {
let tok = word.strip_prefix('\\').unwrap_or(word);
let short =
|flag: char| tok.starts_with('-') && !tok.starts_with("--") && tok.contains(flag);
match tok.rsplit('/').next().unwrap_or(tok) {
"sudo" | "doas" => return true,
"env" | "genv" => in_env = true,
"exec" => in_exec = true,
_ if in_env && (tok == "-" || tok == "--ignore-environment" || short('i')) => {
return true;
}
_ if in_exec && short('c') => return true,
_ => {}
}
}
false
}