use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use libtmux::ServerGeneration;
use serde::Serialize;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Relation {
#[default]
Unknown,
#[serde(rename = "self")]
Own,
Other,
}
#[derive(Clone, Eq, PartialEq)]
pub struct CallerIdentity {
socket: Option<PathBuf>,
server_pid: Option<u32>,
session_id: Option<String>,
pane_id: Option<String>,
malformed: bool,
}
impl fmt::Debug for CallerIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CallerIdentity")
.field("socket", &self.socket.as_ref().map(|_| "<redacted>"))
.field("server_pid", &self.server_pid)
.field("session_id", &self.session_id)
.field("pane_id", &self.pane_id)
.field("malformed", &self.malformed)
.finish()
}
}
fn canonical_number(value: &str) -> Option<u64> {
let parsed = value.parse::<u64>().ok()?;
(parsed.to_string() == value).then_some(parsed)
}
fn canonical_pane_id(value: &str) -> bool {
value
.parse::<libtmux::PaneId>()
.is_ok_and(|parsed| parsed.to_string() == value)
}
impl CallerIdentity {
#[must_use]
pub fn from_env() -> Option<Self> {
Self::from_values(std::env::var_os("TMUX"), std::env::var_os("TMUX_PANE"))
}
#[must_use]
pub fn from_values(tmux: Option<OsString>, pane: Option<OsString>) -> Option<Self> {
let tmux = tmux.filter(|value| !value.is_empty());
let pane = pane.filter(|value| !value.is_empty());
let detached = tmux.is_none() && pane.is_none();
if detached {
return None;
}
let parsed = tmux
.and_then(|value| value.into_string().ok())
.zip(pane.and_then(|value| value.into_string().ok()))
.and_then(|(tmux, pane_id)| {
let (socket, suffix) = tmux.rsplit_once(',')?;
let (socket, server_pid) = socket.rsplit_once(',')?;
let socket = PathBuf::from(socket);
let server_pid = canonical_number(server_pid)
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value != 0)?;
let session =
canonical_number(suffix).and_then(|value| u32::try_from(value).ok())?;
if !socket.is_absolute()
|| !crate::exec::route_path_is_terminal_safe(socket.as_os_str())
|| !canonical_pane_id(&pane_id)
{
return None;
}
Some((socket, server_pid, format!("${session}"), pane_id))
});
Some(match parsed {
Some((socket, server_pid, session_id, pane_id)) => Self {
socket: Some(socket),
server_pid: Some(server_pid),
session_id: Some(session_id),
pane_id: Some(pane_id),
malformed: false,
},
None => Self {
socket: None,
server_pid: None,
session_id: None,
pane_id: None,
malformed: true,
},
})
}
#[must_use]
pub const fn is_malformed(&self) -> bool {
self.malformed
}
#[must_use]
pub fn pane_id(&self) -> Option<&str> {
self.pane_id.as_deref()
}
#[must_use]
pub fn socket(&self) -> Option<&Path> {
self.socket.as_deref()
}
pub(crate) fn resolve_on<'a>(
&'a self,
server_socket: &Path,
generation: ServerGeneration,
panes: &[libtmux::Pane],
) -> Result<Option<&'a str>, &'static str> {
if self.malformed {
return Err("malformed");
}
let (Some(socket), Some(server_pid), Some(session_id), Some(pane_id)) = (
self.socket.as_deref(),
self.server_pid,
self.session_id.as_deref(),
self.pane_id.as_deref(),
) else {
return Err("incomplete");
};
if !same_path(socket, server_socket) {
return Ok(None);
}
if server_pid != generation.pid() {
return Err("stale server process");
}
if !panes.iter().any(|pane| {
pane.id().to_string() == pane_id && pane.session_id().to_string() == session_id
}) {
return Err("unresolved pane and session");
}
Ok(Some(pane_id))
}
#[must_use]
pub fn relation_to(&self, pane_id: &str, server_socket: Option<&Path>) -> Relation {
if self.malformed {
return Relation::Other;
}
if self.pane_id.as_deref() != Some(pane_id) {
return Relation::Other;
}
if same_socket(self.socket.as_deref(), server_socket) {
Relation::Own
} else {
Relation::Other
}
}
#[must_use]
pub fn may_be_on(&self, server_socket: Option<&Path>, _socket_name: Option<&str>) -> bool {
if self.malformed {
return true;
}
let Some(caller) = self.socket.as_deref() else {
return self.pane_id.is_some();
};
let Some(target) = server_socket else {
return true;
};
if same_path(caller, target) {
return true;
}
false
}
}
fn same_socket(caller: Option<&Path>, target: Option<&Path>) -> bool {
match (caller, target) {
(Some(caller), Some(target)) => same_path(caller, target),
_ => false,
}
}
fn same_path(left: &Path, right: &Path) -> bool {
crate::run_request::same_endpoint(left, right)
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
#[test]
fn any_nonempty_caller_variable_is_not_detached() {
for (tmux, pane) in [
(Some(OsString::from("/tmp/socket,1,0")), None),
(None, Some(OsString::from("%0"))),
(Some(OsString::new()), Some(OsString::from("%0"))),
(
Some(OsString::from("/tmp/socket,not-a-pid,0")),
Some(OsString::from("%0")),
),
] {
let caller = CallerIdentity::from_values(tmux, pane)
.expect("only two absent variables represent a detached caller");
assert!(caller.malformed);
assert!(caller.is_malformed());
}
}
#[test]
fn empty_caller_variables_are_detached() {
for (tmux, pane) in [
(Some(OsString::new()), Some(OsString::new())),
(Some(OsString::new()), None),
(None, Some(OsString::new())),
] {
assert_eq!(CallerIdentity::from_values(tmux, pane), None);
}
}
#[test]
fn a_pane_without_a_socket_is_malformed() {
let caller = CallerIdentity::from_values(None, Some(OsString::from("%3")))
.expect("a present variable is not detached");
assert!(caller.malformed);
assert_eq!(caller.pane_id(), None);
}
use super::*;
fn identity(tmux: &str, pane: &str) -> CallerIdentity {
let caller = CallerIdentity::from_values(Some(tmux.into()), Some(pane.into()))
.unwrap_or_else(|| unreachable!("both values are present"));
assert!(!caller.malformed, "fixture identity is complete");
caller
}
#[test]
fn an_absent_environment_is_no_identity() {
assert_eq!(CallerIdentity::from_values(None, None), None);
}
#[test]
fn a_pane_without_tmux_is_not_usable_identity() {
let caller = CallerIdentity::from_values(None, Some("%3".into()));
let caller = caller.unwrap_or_else(|| unreachable!("a pane is present"));
assert!(caller.malformed);
assert_eq!(caller.pane_id(), None);
assert_eq!(caller.socket(), None);
}
#[test]
fn tmux_carries_socket_pid_and_session() {
let caller = identity("/tmp/tmux-1000/a,comma,48188,10", "%3");
assert_eq!(caller.socket(), Some(Path::new("/tmp/tmux-1000/a,comma")));
assert_eq!(caller.server_pid, Some(48188));
assert_eq!(caller.session_id.as_deref(), Some("$10"));
assert_eq!(caller.pane_id(), Some("%3"));
}
#[test]
fn caller_fields_require_the_exact_tmux_encoding() {
for (tmux, pane) in [
("/tmp/socket,0,0", "%0"),
("/tmp/socket,01,0", "%0"),
("/tmp/socket,1,00", "%0"),
("/tmp/socket,1,$0", "%0"),
("/tmp/socket,1,4294967296", "%0"),
("/tmp/socket,1,0", "%00"),
("relative/socket,1,0", "%0"),
("/tmp/socket,1,0,extra", "%0"),
] {
let caller = CallerIdentity::from_values(Some(tmux.into()), Some(pane.into()))
.expect("present context");
assert!(caller.malformed, "{tmux} {pane}");
}
}
#[test]
fn caller_socket_rejects_ascii_terminal_controls() {
for byte in (0..=0x1f).chain([0x7f]) {
let mut tmux = b"/tmp/socket-".to_vec();
tmux.push(byte);
tmux.extend_from_slice(b",1,0");
let caller = CallerIdentity::from_values(
Some(OsString::from_vec(tmux)),
Some(OsString::from("%0")),
)
.expect("present context");
assert!(caller.malformed, "byte {byte:#04x} was accepted");
}
}
#[tokio::test]
async fn caller_socket_hard_link_resolves_on_the_same_daemon() {
let guard = libtmux::test::TestServer::builder()
.start()
.await
.expect("tmux starts");
let server = guard.server();
let session = server
.new_session("caller-hard-link")
.await
.expect("session starts");
let pane = session.panes().await.expect("panes list").remove(0);
let generation = server.generation().await.expect("server generation");
let alias = server.socket_path().with_file_name("caller-hard-link.sock");
std::fs::hard_link(server.socket_path(), &alias).expect("socket hard link is created");
let caller = CallerIdentity::from_values(
Some(
format!(
"{},{},{}",
alias.display(),
generation.pid(),
session.id().as_ref().trim_start_matches('$')
)
.into(),
),
Some(pane.id().as_ref().into()),
)
.expect("caller context is present");
let panes = server.panes().await.expect("pane snapshot");
assert_eq!(
caller
.resolve_on(server.socket_path(), generation, &panes)
.expect("physical alias is authenticated"),
Some(pane.id().as_ref())
);
std::fs::remove_file(&alias).expect("socket hard link is removed");
guard.shutdown().await.expect("tmux fixture shuts down");
}
#[test]
fn debug_surfaces_redact_the_socket_path() {
let path = "/tmp/libtmux-rs-test/caller-debug.sock";
let caller = identity(&format!("{path},48188,10"), "%3");
let builder = crate::TmuxTools::builder(
libtmux::Server::builder()
.socket_path("/tmp/libtmux-rs-test/caller-target.sock")
.build()
.expect("an inert server builds"),
)
.caller(Some(caller.clone()));
let surfaces = [
format!("{caller:?}"),
format!("{builder:?}"),
format!("{:?}", builder.build()),
];
for surface in surfaces {
assert!(surface.contains("CallerIdentity"), "{surface}");
assert!(!surface.contains(path), "{surface}");
}
}
#[test]
fn a_truncated_tmux_value_is_malformed() {
let caller = CallerIdentity::from_values(Some("/tmp/sock".into()), Some("%1".into()))
.expect("present context");
assert!(caller.malformed);
assert_eq!(caller.socket(), None);
}
#[test]
fn extra_fields_make_the_context_malformed() {
let caller =
CallerIdentity::from_values(Some("/tmp/sock,1,1,extra".into()), Some("%1".into()))
.expect("present context");
assert!(caller.malformed);
assert_eq!(caller.socket(), None);
}
#[test]
fn the_same_pane_on_the_same_socket_is_the_callers_own() {
let caller = identity("/tmp/sock,1,0", "%1");
assert_eq!(
caller.relation_to("%1", Some(Path::new("/tmp/sock"))),
Relation::Own
);
}
#[test]
fn the_same_pane_id_on_another_socket_is_not() {
let caller = identity("/tmp/sock-a,1,0", "%1");
assert_eq!(
caller.relation_to("%1", Some(Path::new("/tmp/sock-b"))),
Relation::Other,
"a pane id is only unique within one server"
);
}
#[test]
fn an_unprovable_socket_annotates_as_other() {
let caller = CallerIdentity::from_values(None, Some("%1".into()));
let caller = caller.unwrap_or_else(|| unreachable!("a pane is present"));
assert_eq!(
caller.relation_to("%1", Some(Path::new("/tmp/sock"))),
Relation::Other,
"the annotation states fact, so it declines what it cannot prove"
);
}
#[test]
fn a_different_pane_is_other() {
let caller = identity("/tmp/sock,1,0", "%1");
assert_eq!(
caller.relation_to("%2", Some(Path::new("/tmp/sock"))),
Relation::Other
);
}
#[test]
fn a_mangled_socket_is_no_evidence_rather_than_contrary_evidence() {
for mangled in ["garbage-with-no-commas", "relative/path,1,0", "..,1,0"] {
let caller = CallerIdentity::from_values(Some(mangled.into()), Some("%1".into()))
.expect("present context");
assert!(caller.malformed);
assert_eq!(
caller.socket(),
None,
"{mangled} should not parse as a socket"
);
assert!(
caller.may_be_on(Some(Path::new("/tmp/tmux-1000/default")), Some("default")),
"{mangled} must leave the guard cautious"
);
assert_eq!(
caller.relation_to("%1", Some(Path::new("/tmp/tmux-1000/default"))),
Relation::Other,
"{mangled} proves nothing, so the annotation declines"
);
}
}
#[test]
fn the_guard_blocks_what_the_annotation_declines() {
let caller = CallerIdentity::from_values(None, Some("%1".into()));
let caller = caller.unwrap_or_else(|| unreachable!("a pane is present"));
assert!(
caller.may_be_on(Some(Path::new("/tmp/sock")), Some("default")),
"with no socket to compare, a kill must assume the worst"
);
}
#[test]
fn the_guard_blocks_when_the_target_socket_is_unreadable() {
let caller = identity("/tmp/sock,1,0", "%1");
assert!(caller.may_be_on(None, None));
}
#[test]
fn the_guard_does_not_authenticate_a_basename_match() {
let caller = identity("/private/tmp/tmux-1000/work,1,0", "%1");
assert!(!caller.may_be_on(Some(Path::new("/tmp/tmux-1000/work")), Some("work")));
}
#[test]
fn the_guard_clears_an_unrelated_server() {
let caller = identity("/tmp/tmux-1000/default,1,0", "%1");
assert!(
!caller.may_be_on(Some(Path::new("/tmp/tmux-1000/other")), Some("other")),
"a different socket by both path and name is a different server"
);
}
}