use std::ffi::{OsStr, OsString};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::os::unix::ffi::OsStringExt as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[cfg(feature = "control-mode")]
use crate::SessionId;
use crate::formats::{TmuxText, TransportDialect, split_quoted_rows};
use crate::internal::core::Core;
use crate::internal::listing;
#[cfg(feature = "control-mode")]
use crate::internal::process::PersistentChild;
use crate::internal::scoped;
use crate::pane::Pane;
use crate::session::Session;
use crate::{
Command, CommandChain, CommandResult, EngineCapabilities, Error, ListingDecodeError,
ReleaseSuffix, ReleaseVersion, ServerConfigurationErrorKind, ServerGeneration, ServerIdentity,
TmuxArg, TmuxVersion,
};
mod builder;
mod channels;
mod discovery;
mod interactive;
pub use interactive::MenuItem;
mod keys;
#[cfg(feature = "unstable-fuzzing")]
#[doc(hidden)]
pub use keys::__fuzz_parse_key_bindings;
pub use keys::KeyBinding;
mod settings;
pub use builder::ServerBuilder;
pub use discovery::{SessionTree, WindowTree};
#[cfg(feature = "query")]
pub use discovery::{SessionTreeFields, WindowTreeFields};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AccessMode {
ReadOnly,
Write,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Principal {
User,
Group,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AccessRule {
name: String,
principal: Principal,
mode: AccessMode,
}
impl AccessRule {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn principal(&self) -> Principal {
self.principal
}
#[must_use]
pub const fn mode(&self) -> AccessMode {
self.mode
}
}
fn parse_access_rule(line: &str) -> Result<AccessRule, Error> {
let (name, marker) = line
.rsplit_once(' ')
.ok_or_else(|| Error::unreadable_access_rule(line))?;
let inner = marker
.strip_prefix('(')
.and_then(|marker| marker.strip_suffix(')'))
.ok_or_else(|| Error::unreadable_access_rule(marker))?;
let (principal, mode) = match inner.split_once(',') {
Some(("U", mode)) => (Principal::User, mode),
Some(("G", mode)) => (Principal::Group, mode),
Some(_) => return Err(Error::unreadable_access_rule(marker)),
None => (Principal::User, inner),
};
let mode = match mode {
"R" => AccessMode::ReadOnly,
"W" => AccessMode::Write,
_ => return Err(Error::unreadable_access_rule(marker)),
};
Ok(AccessRule {
name: name.to_owned(),
principal,
mode,
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PromptKind {
Command,
Search,
Target,
WindowTarget,
}
impl PromptKind {
const fn name(self) -> &'static str {
match self {
Self::Command => "command",
Self::Search => "search",
Self::Target => "target",
Self::WindowTarget => "window-target",
}
}
}
#[derive(Clone)]
pub struct Server {
core: Arc<Core>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum ChannelWait {
Signalled,
TimedOut,
}
impl Server {
pub(crate) const fn from_core(core: Arc<Core>) -> Self {
Self { core }
}
pub(crate) async fn endpoint_version(&self) -> Result<TmuxVersion, Error> {
self.core.endpoint_version().await
}
#[cfg(feature = "test-support")]
pub(crate) async fn fixture_pid(&self) -> Result<CommandResult, Error> {
self.core.fixture_pid().await
}
#[cfg(feature = "control-mode")]
pub(crate) async fn spawn_control(
&self,
session: &SessionId,
) -> Result<(PersistentChild, TmuxVersion), Error> {
self.core.spawn_control(session).await
}
#[cfg(feature = "control-mode")]
#[must_use]
pub fn owns_control_client(&self, pid: u32) -> bool {
self.core.owns_control_client(pid)
}
pub fn new() -> Result<Self, Error> {
Self::builder().build()
}
pub fn builder() -> ServerBuilder {
ServerBuilder::new()
}
#[must_use]
pub fn identity(&self) -> &ServerIdentity {
self.core.configuration().identity()
}
#[must_use]
pub fn socket_path(&self) -> &Path {
self.identity().socket_path()
}
#[must_use]
pub fn socket_name(&self) -> Option<&OsStr> {
self.core.configuration().socket_name()
}
#[must_use]
pub fn config_file(&self) -> Option<&Path> {
self.core.configuration().config_file()
}
#[must_use]
pub fn colors(&self) -> Option<u16> {
self.core.configuration().colors()
}
#[must_use]
pub fn tmux_executable(&self) -> &OsStr {
self.core.configuration().executable()
}
#[must_use]
pub fn resolved_tmux_executable(&self) -> Option<PathBuf> {
self.core.configuration().resolved_executable()
}
#[must_use]
pub fn default_timeout(&self) -> Duration {
self.core.configuration().timeout()
}
pub async fn capabilities(&self) -> Result<&EngineCapabilities, Error> {
self.core.capabilities().await
}
pub async fn daemon_version(&self) -> Result<TmuxVersion, Error> {
self.core.format_version().await
}
pub async fn cmd(&self, command: Command) -> Result<CommandResult, Error> {
self.core.execute(command).await
}
pub async fn validate_layouts<'a>(
&self,
layouts: impl IntoIterator<Item = (&'a OsStr, usize)>,
) -> Result<(), Error> {
let pending = crate::layout::prepare(layouts)?;
crate::layout::resolve(&pending, &crate::layout::version(self).await?)?;
Ok(())
}
pub async fn chain(&self, chain: CommandChain) -> Result<CommandResult, Error> {
self.core.execute_chain(chain).await
}
pub async fn shutdown(&self) -> Result<(), Error> {
self.core.shutdown().await
}
pub fn from_env() -> Result<Self, Error> {
Self::from_env_value(std::env::var_os("TMUX"))
}
pub fn from_env_value(value: Option<impl Into<OsString>>) -> Result<Self, Error> {
use std::os::unix::ffi::OsStrExt as _;
let value: OsString = value.map(Into::into).ok_or_else(|| {
Error::invalid_server_configuration(ServerConfigurationErrorKind::NotInsideTmux)
})?;
let bytes = value.as_bytes();
let socket = bytes
.iter()
.position(|byte| *byte == b',')
.map(|index| &bytes[..index])
.filter(|socket| !socket.is_empty())
.ok_or_else(|| {
Error::invalid_server_configuration(
ServerConfigurationErrorKind::MalformedTmuxVariable,
)
})?;
Self::builder()
.socket_path(PathBuf::from(OsString::from_vec(socket.to_vec())))
.build()
}
pub async fn attached_sessions(&self) -> Result<Vec<Session>, Error> {
let mut sessions = self.sessions().await?;
sessions.retain(Session::is_attached);
Ok(sessions)
}
pub async fn new_session(
&self,
options: impl Into<NewSessionOptions>,
) -> Result<Session, Error> {
let options = options.into();
let info =
listing::create_session(&self.core, |format| options.into_command(format)).await?;
Ok(Session::new(Arc::clone(&self.core), info))
}
pub async fn lock_all(&self) -> Result<(), Error> {
listing::mutate(&self.core, "lock-server", Command::new("lock-server")).await
}
pub async fn kill(&self) -> Result<(), Error> {
let result = self.core.kill_server().await?;
if result.success() {
return Ok(());
}
if self.is_alive().await {
return Err(Error::CommandFailed {
command: "kill-server",
exit_code: result.exit_code(),
stderr: result.stderr_lossy().into_owned(),
});
}
Ok(())
}
pub async fn set_buffer(
&self,
name: Option<&str>,
data: impl Into<OsString>,
) -> Result<(), Error> {
let mut command = Command::new("set-buffer");
if let Some(name) = name {
command = command.arg("-b").arg(OsString::from(name));
}
command = command.arg("--");
listing::mutate(&self.core, "set-buffer", command.sensitive_arg(data.into())).await
}
pub async fn load_buffer(
&self,
name: Option<&str>,
path: impl AsRef<Path>,
) -> Result<(), Error> {
let mut command = Command::new("load-buffer");
if let Some(name) = name {
command = command.arg("-b").arg(OsString::from(name));
}
listing::mutate(
&self.core,
"load-buffer",
command
.arg("--")
.arg(path.as_ref().as_os_str().to_os_string()),
)
.await
}
pub async fn save_buffer(
&self,
name: Option<&str>,
path: impl AsRef<Path>,
) -> Result<(), Error> {
let mut command = Command::new("save-buffer");
if let Some(name) = name {
command = command.arg("-b").arg(OsString::from(name));
}
listing::mutate(
&self.core,
"save-buffer",
command
.arg("--")
.arg(path.as_ref().as_os_str().to_os_string()),
)
.await
}
pub async fn buffer(&self, name: impl AsRef<[u8]>) -> Result<Option<Vec<u8>>, Error> {
let result = self
.cmd(
Command::new("show-buffer")
.arg("-b")
.arg(OsString::from_vec(name.as_ref().to_vec())),
)
.await?;
if result.success() {
Ok(Some(result.stdout().to_vec()))
} else {
Ok(None)
}
}
pub async fn buffer_names(&self) -> Result<Vec<TmuxText>, Error> {
let dialect = TransportDialect::for_version(&self.core.format_version().await?);
let result = self
.cmd(
Command::new("list-buffers")
.arg("-F")
.arg("#{q:buffer_name}="),
)
.await?;
if !result.success() {
return Err(Error::CommandFailed {
command: "list-buffers",
exit_code: result.exit_code(),
stderr: result.stderr_lossy().into_owned(),
});
}
let rows =
split_quoted_rows(result.stdout(), ["buffer_name"], dialect).map_err(|detail| {
Error::DecodeListing {
list_command: "list-buffers",
detail: ListingDecodeError::new(detail),
}
})?;
Ok(rows
.into_iter()
.map(|[name]| TmuxText::from(name))
.collect())
}
pub async fn delete_buffer(&self, name: impl AsRef<[u8]>) -> Result<(), Error> {
listing::mutate(
&self.core,
"delete-buffer",
Command::new("delete-buffer")
.arg("-b")
.arg(OsString::from_vec(name.as_ref().to_vec())),
)
.await
}
pub async fn bind_key(
&self,
table: &str,
key: &str,
command: impl Into<OsString>,
) -> Result<(), Error> {
listing::mutate(
&self.core,
"bind-key",
Command::new("bind-key")
.arg("-T")
.arg(OsString::from(table))
.arg("--")
.arg(OsString::from(key))
.arg(command.into()),
)
.await
}
pub async fn unbind_key(&self, table: &str, key: &str) -> Result<(), Error> {
listing::mutate(
&self.core,
"unbind-key",
Command::new("unbind-key")
.arg("-T")
.arg(OsString::from(table))
.arg("--")
.arg(OsString::from(key)),
)
.await
}
pub async fn key_bindings(&self, table: Option<&str>) -> Result<Vec<String>, Error> {
let mut command = Command::new("list-keys");
if let Some(table) = table {
command = command.arg("-T").arg(OsString::from(table));
}
let result = self.cmd(command).await?;
if !result.success() {
return Err(Error::CommandFailed {
command: "list-keys",
exit_code: result.exit_code(),
stderr: result.stderr_lossy().into_owned(),
});
}
Ok(result
.stdout_lossy()
.lines()
.map(ToOwned::to_owned)
.collect())
}
pub async fn format(&self, pane: Option<&Pane>, format: &str) -> Result<TmuxText, Error> {
let mut command = Command::new("display-message").arg("-p");
if let Some(pane) = pane {
self.core
.require_same_server(pane.server_identity(), "display-message")?;
command = command.arg("-t").arg(pane.id().to_string());
}
let result = self
.cmd(command.arg("--").arg(OsString::from(format)))
.await?;
if !result.success() {
return Err(Error::from_refused_result("display-message", &result, None));
}
let stdout = result.stdout();
let value = stdout.strip_suffix(b"\n").unwrap_or(stdout);
Ok(TmuxText::from(value.to_vec()))
}
pub(crate) async fn require(
&self,
capability: &'static str,
needs: ReleaseVersion,
) -> Result<(), Error> {
self.capabilities()
.await?
.tmux_version()
.require(capability, needs)
}
async fn refuse_if_defective(
&self,
capability: &'static str,
broken_in: ReleaseVersion,
fixed_in: ReleaseVersion,
) -> Result<(), Error> {
let found = self.capabilities().await?.tmux_version();
if found
.behavior_release()
.is_some_and(|release| release >= broken_in && release < fixed_in)
{
return Err(Error::CapabilityDefective {
capability,
found: found.clone(),
broken_in,
fixed_in,
});
}
Ok(())
}
pub async fn prompt_history(&self, kind: PromptKind) -> Result<Vec<TmuxText>, Error> {
let result = self
.cmd(
Command::new("show-prompt-history")
.arg("-T")
.arg(kind.name()),
)
.await?;
if !result.success() {
return Err(Error::from_refused_result(
"show-prompt-history",
&result,
None,
));
}
Ok(result
.stdout_lossy()
.lines()
.skip_while(|line| line.starts_with("History for "))
.filter(|line| !line.is_empty())
.map(|line| TmuxText::from(line.as_bytes().to_vec()))
.collect())
}
pub async fn clear_prompt_history(&self) -> Result<(), Error> {
listing::mutate(
&self.core,
"clear-prompt-history",
Command::new("clear-prompt-history"),
)
.await
}
pub async fn access_rules(&self) -> Result<Vec<AccessRule>, Error> {
let result = self.cmd(Command::new("server-access").arg("-l")).await?;
if !result.success() {
return Err(Error::from_refused_result("server-access", &result, None));
}
result
.stdout_lossy()
.lines()
.map(parse_access_rule)
.collect()
}
pub async fn grant_access(&self, user: &str, mode: AccessMode) -> Result<(), Error> {
listing::mutate(
&self.core,
"server-access",
Command::new("server-access")
.arg("-a")
.arg(match mode {
AccessMode::ReadOnly => "-r",
AccessMode::Write => "-w",
})
.arg("--")
.arg(OsString::from(user)),
)
.await
}
pub async fn revoke_access(&self, user: &str) -> Result<(), Error> {
listing::mutate(
&self.core,
"server-access",
Command::new("server-access")
.arg("-d")
.arg("--")
.arg(OsString::from(user)),
)
.await
}
pub async fn source_file(&self, path: impl Into<PathBuf>) -> Result<(), Error> {
listing::mutate(
&self.core,
"source-file",
Command::new("source-file")
.arg("--")
.arg(path.into().into_os_string()),
)
.await
}
pub async fn with_session<T, E>(
&self,
options: impl Into<NewSessionOptions>,
operation: impl AsyncFnOnce(&Session) -> Result<T, E>,
) -> Result<T, crate::ScopeError<T, E>> {
let server = self.clone();
let options = options.into();
scoped::run(
"with-session",
async move { server.new_session(options).await },
Session::kill,
operation,
)
.await
}
pub async fn run_shell(&self, command: impl Into<OsString>) -> Result<Vec<TmuxText>, Error> {
self.refuse_if_defective(
"run-shell output",
ReleaseVersion::new(3, 3, ReleaseSuffix::FINAL),
ReleaseVersion::new(3, 5, ReleaseSuffix::FINAL),
)
.await?;
let result = self
.cmd(
Command::new("run-shell")
.arg("--")
.sensitive_arg(command.into()),
)
.await?;
if !result.success() {
return Err(Error::from_refused_result("run-shell", &result, None));
}
let stdout = result.stdout();
let stdout = stdout.strip_suffix(b"\n").unwrap_or(stdout);
if stdout.is_empty() {
return Ok(Vec::new());
}
Ok(stdout
.split(|byte| *byte == b'\n')
.map(|line| TmuxText::from(line.to_vec()))
.collect())
}
pub async fn spawn_shell(&self, command: impl Into<OsString>) -> Result<(), Error> {
listing::mutate(
&self.core,
"run-shell",
Command::new("run-shell")
.arg("-b")
.arg("--")
.sensitive_arg(command.into()),
)
.await
}
pub async fn start(&self) -> Result<(), Error> {
listing::mutate(&self.core, "start-server", Command::new("start-server")).await
}
pub async fn generation(&self) -> Result<ServerGeneration, Error> {
let answer = self.format(None, "#{pid} #{start_time}").await?;
let text = answer.to_string_lossy();
let mut parts = text.split_whitespace();
let pid = parts.next().and_then(|pid| pid.parse::<u32>().ok());
let start_time = parts
.next()
.and_then(|start| start.parse::<i64>().ok())
.filter(|start| crate::snapshot::unix_time(*start).is_some());
let (Some(pid), Some(start_time)) = (pid, start_time) else {
return Err(Error::UnreadableFormatValue {
format: "#{pid} #{start_time}",
detail: crate::IdParseError::new('#'),
});
};
Ok(ServerGeneration { pid, start_time })
}
pub async fn require_generation(&self, expected: ServerGeneration) -> Result<(), Error> {
let found = self.generation().await?;
if found == expected {
return Ok(());
}
Err(Error::ServerGenerationChanged { expected, found })
}
pub async fn is_alive(&self) -> bool {
self.check_alive().await.is_ok()
}
pub async fn check_alive(&self) -> Result<(), Error> {
let result = self.cmd(Command::new("list-sessions")).await?;
if result.success() {
return Ok(());
}
Err(Error::from_refused_result("list-sessions", &result, None))
}
#[cfg(test)]
pub(crate) fn from_executor_for_test(
executor: Arc<dyn crate::internal::executor::Executor>,
) -> Self {
Self {
core: Arc::new(Core::from_executor_for_test(executor)),
}
}
#[cfg(feature = "control-mode")]
pub async fn over_control_mode(
&self,
sender: &crate::control::ControlSender,
) -> Result<Self, Error> {
self.core
.require_same_server(sender.identity(), "over_control_mode")?;
let capabilities = self.core.capabilities().await?.clone();
Ok(Self {
core: Arc::new(self.core.over_control_mode(sender.clone(), capabilities)),
})
}
#[cfg(all(feature = "control-mode", feature = "plan"))]
pub(crate) fn routes_over_control_mode(&self) -> bool {
self.core.routes_over_control_mode()
}
}
impl PartialEq for Server {
fn eq(&self, other: &Self) -> bool {
self.identity() == other.identity()
}
}
impl Eq for Server {}
impl Hash for Server {
fn hash<H: Hasher>(&self, state: &mut H) {
self.identity().hash(state);
}
}
impl fmt::Debug for Server {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Server")
.field("identity", self.identity())
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use std::os::unix::process::ExitStatusExt as _;
use std::process::ExitStatus;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use tokio::sync::{Notify, watch};
use super::{AccessMode, NewSessionOptions, Principal, Server, parse_access_rule};
use crate::command::{CommandRequest, CommandResult, ProcessStatus};
use crate::formats::{DecoderKind, FormatDescriptor, FormatPlan, ListProfile};
use crate::internal::executor::{DispatchFuture, Executor, ShutdownFuture};
use crate::{Error, ErrorKind, TmuxVersion};
struct RefusingExecutor {
calls: AtomicUsize,
}
#[derive(Clone, Copy)]
enum SessionFollowup {
DispatchError,
EmptyListing,
}
struct ComposedSessionExecutor {
calls: AtomicUsize,
sessions_stdout: Vec<u8>,
followup: SessionFollowup,
}
impl Executor for ComposedSessionExecutor {
fn execute(&self, request: CommandRequest) -> DispatchFuture {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
let sessions_stdout = self.sessions_stdout.clone();
let followup = self.followup;
DispatchFuture::new(async move {
if call == 3 && matches!(followup, SessionFollowup::DispatchError) {
return Err(Error::Overloaded {
request_id: request.request_id().get(),
command: request.summary().clone(),
in_flight: 1,
});
}
let stdout = match call {
0 => b"tmux 3.7b\n".to_vec(),
1 => sessions_stdout,
2 | 3 => Vec::new(),
_ => panic!("one probe, one listing, one mutation, and one refresh"),
};
Ok(CommandResult::new(
request.request_id(),
request.summary().clone(),
ProcessStatus::from_exit_status(ExitStatus::from_raw(0)),
stdout,
Vec::new(),
))
})
}
fn shutdown(&self) -> ShutdownFuture {
ShutdownFuture::new(async { Ok(()) })
}
}
fn default_format_value(descriptor: &FormatDescriptor) -> &'static [u8] {
match descriptor.name() {
"session_id" => b"$1",
"window_id" => b"@1",
"pane_id" => b"%1",
"client_name" => b"client",
_ => match descriptor.decoder() {
DecoderKind::Ascii => b"ascii",
DecoderKind::Text => b"text",
DecoderKind::Bool
| DecoderKind::U8
| DecoderKind::U32
| DecoderKind::U64
| DecoderKind::I32
| DecoderKind::Timestamp
| DecoderKind::PaneProgress => b"0",
DecoderKind::SessionId => b"$1",
DecoderKind::WindowId => b"@1",
DecoderKind::PaneId => b"%1",
DecoderKind::PaneProgressState => b"normal",
},
}
}
fn session_listing_stdout() -> Vec<u8> {
let version = TmuxVersion::parse_output(b"tmux 3.7b\n").expect("fixture version");
let plan = FormatPlan::for_profile(ListProfile::Sessions, &version);
let mut stdout = Vec::new();
for descriptor in plan.descriptors_for_test() {
for byte in default_format_value(descriptor) {
if matches!(*byte, b'\\' | b'%' | b'=') {
stdout.push(b'\\');
}
stdout.push(*byte);
}
stdout.push(b'=');
}
stdout.push(b'\n');
stdout
}
impl Executor for RefusingExecutor {
fn execute(&self, request: CommandRequest) -> DispatchFuture {
let call = self.calls.fetch_add(1, Ordering::SeqCst);
DispatchFuture::new(async move {
let (status, stdout, stderr) = if call == 0 {
(0, b"tmux 3.7b\n".to_vec(), Vec::new())
} else {
assert_eq!(call, 1, "one probe and one run-shell dispatch");
assert_eq!(request.summary().sensitive_argument_count(), 1);
(1 << 8, Vec::new(), b"sentinel-run-shell-output\n".to_vec())
};
Ok(CommandResult::new(
request.request_id(),
request.summary().clone(),
ProcessStatus::from_exit_status(ExitStatus::from_raw(status)),
stdout,
stderr,
))
})
}
fn shutdown(&self) -> ShutdownFuture {
ShutdownFuture::new(async { Ok(()) })
}
}
struct BlockingShutdownExecutor {
closed: AtomicBool,
shutdown_started: Notify,
release: watch::Receiver<bool>,
}
impl Executor for BlockingShutdownExecutor {
fn execute(&self, request: CommandRequest) -> DispatchFuture {
let closed = self.closed.load(Ordering::SeqCst);
DispatchFuture::new(async move {
assert!(
closed,
"test dispatch occurs only after shutdown closes admission"
);
Err(Error::executor_shutdown(
request.request_id().get(),
request.summary().clone(),
))
})
}
fn shutdown(&self) -> ShutdownFuture {
self.closed.store(true, Ordering::SeqCst);
self.shutdown_started.notify_one();
let mut release = self.release.clone();
ShutdownFuture::new(async move {
while !*release.borrow() {
release
.changed()
.await
.expect("test release sender remains alive");
}
Ok(())
})
}
}
#[tokio::test]
async fn aborting_one_server_shutdown_keeps_all_clones_closed_until_later_completion() {
let (release_sender, release_receiver) = watch::channel(false);
let executor = Arc::new(BlockingShutdownExecutor {
closed: AtomicBool::new(false),
shutdown_started: Notify::new(),
release: release_receiver,
});
let server = Server::from_executor_for_test(executor.clone());
let started = executor.shutdown_started.notified();
let shutdown_server = server.clone();
let shutdown = tokio::spawn(async move { shutdown_server.shutdown().await });
started.await;
shutdown.abort();
assert!(
shutdown
.await
.expect_err("shutdown task was aborted")
.is_cancelled()
);
assert!(matches!(
server.cmd(crate::Command::new("display-message")).await,
Err(Error::ExecutorShutdown { .. })
));
release_sender
.send(true)
.expect("test release receiver remains alive");
server
.clone()
.shutdown()
.await
.expect("later clone completes shutdown");
}
#[tokio::test]
async fn run_shell_failure_withholds_sensitive_output() {
let server = Server::from_executor_for_test(Arc::new(RefusingExecutor {
calls: AtomicUsize::new(0),
}));
let error = server
.run_shell("sentinel-run-shell-command")
.await
.expect_err("the command is refused");
let diagnostic = format!("{error:?} {error}");
for secret in ["sentinel-run-shell-command", "sentinel-run-shell-output"] {
assert!(!diagnostic.contains(secret), "{diagnostic}");
}
}
#[tokio::test]
async fn session_refresh_failure_after_rename_marks_the_completed_effect() {
let executor = Arc::new(ComposedSessionExecutor {
calls: AtomicUsize::new(0),
sessions_stdout: session_listing_stdout(),
followup: SessionFollowup::DispatchError,
});
let server = Server::from_executor_for_test(executor.clone());
let mut session = server
.sessions()
.await
.expect("fixture session listing")
.pop()
.expect("one fixture session");
let error = session
.rename("renamed")
.await
.expect_err("refresh dispatch fails after rename succeeds");
assert_eq!(executor.calls.load(Ordering::SeqCst), 4);
assert_eq!(error.kind(), ErrorKind::PartialEffect);
assert!(
matches!(
error,
Error::AfterEffect { operation: "rename-session", source }
if source.kind() == ErrorKind::Refused && source.is_transient()
),
"the refresh error remains available as the source",
);
}
#[tokio::test]
async fn successful_session_step_requires_an_active_window_postcondition() {
let executor = Arc::new(ComposedSessionExecutor {
calls: AtomicUsize::new(0),
sessions_stdout: session_listing_stdout(),
followup: SessionFollowup::EmptyListing,
});
let server = Server::from_executor_for_test(executor.clone());
let session = server
.sessions()
.await
.expect("fixture session listing")
.pop()
.expect("one fixture session");
let error = session
.next_window()
.await
.expect_err("an accepted step must leave an active window");
assert_eq!(executor.calls.load(Ordering::SeqCst), 4);
assert!(matches!(
error,
Error::AfterEffect { operation: "next-window", source }
if matches!(*source, Error::ObjectGone { .. })
));
}
#[test]
fn new_session_options_redact_the_shell_command() {
let secret = "sentinel-session-command";
let options = NewSessionOptions::new("work").command(secret);
assert!(!format!("{options:?}").contains(secret));
let summary = options.into_command("#{session_id}").summary();
assert_eq!(summary.sensitive_argument_count(), 1);
assert!(!summary.to_string().contains(secret));
}
#[test]
fn access_rule_lines_decode_both_the_legacy_and_the_group_acl_grammar() {
let legacy_write = parse_access_rule("alice (W)").expect("legacy write row parses");
assert_eq!(legacy_write.name(), "alice");
assert_eq!(legacy_write.principal(), Principal::User);
assert_eq!(legacy_write.mode(), AccessMode::Write);
let legacy_read = parse_access_rule("bob (R)").expect("legacy read-only row parses");
assert_eq!(legacy_read.principal(), Principal::User);
assert_eq!(legacy_read.mode(), AccessMode::ReadOnly);
let user_row = parse_access_rule("carol (U,W)").expect("compound user row parses");
assert_eq!(user_row.name(), "carol");
assert_eq!(user_row.principal(), Principal::User);
assert_eq!(user_row.mode(), AccessMode::Write);
let group_row = parse_access_rule("admins (G,R)").expect("compound group row parses");
assert_eq!(group_row.name(), "admins");
assert_eq!(group_row.principal(), Principal::Group);
assert_eq!(group_row.mode(), AccessMode::ReadOnly);
}
#[test]
fn an_unrecognized_access_rule_marker_is_reported_not_dropped() {
let bad_principal = parse_access_rule("mallory (X,W)")
.expect_err("an unknown principal letter is rejected");
assert!(
matches!(&bad_principal, Error::UnreadableAccessRule { marker } if marker == "(X,W)"),
"the refusal names the marker rather than silently dropping the row: \
{bad_principal:?}",
);
assert_eq!(bad_principal.kind(), ErrorKind::Decode);
let bad_mode =
parse_access_rule("mallory (U,X)").expect_err("an unknown mode letter is rejected");
assert!(matches!(
&bad_mode,
Error::UnreadableAccessRule { marker } if marker == "(U,X)"
));
let no_marker =
parse_access_rule("mallory").expect_err("a line with no trailing marker is rejected");
assert!(matches!(no_marker, Error::UnreadableAccessRule { .. }));
}
}
#[must_use = "options describe a session but do not create one"]
#[derive(Clone)]
pub struct NewSessionOptions {
name: TmuxArg,
start_directory: Option<TmuxArg>,
window_name: Option<TmuxArg>,
command: Option<OsString>,
environment: Vec<(OsString, OsString)>,
width: Option<u32>,
height: Option<u32>,
}
impl fmt::Debug for NewSessionOptions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("NewSessionOptions")
.field("has_start_directory", &self.start_directory.is_some())
.field("has_window_name", &self.window_name.is_some())
.field("has_command", &self.command.is_some())
.field("environment_count", &self.environment.len())
.field("width", &self.width)
.field("height", &self.height)
.finish_non_exhaustive()
}
}
impl NewSessionOptions {
pub fn new(name: impl Into<TmuxArg>) -> Self {
Self {
name: name.into(),
start_directory: None,
window_name: None,
command: None,
environment: Vec::new(),
width: None,
height: None,
}
}
pub fn start_directory(mut self, directory: impl Into<TmuxArg>) -> Self {
self.start_directory = Some(directory.into());
self
}
pub fn window_name(mut self, name: impl Into<TmuxArg>) -> Self {
self.window_name = Some(name.into());
self
}
pub fn command(mut self, command: impl Into<OsString>) -> Self {
self.command = Some(command.into());
self
}
pub fn environment(mut self, name: impl Into<OsString>, value: impl Into<OsString>) -> Self {
self.environment.push((name.into(), value.into()));
self
}
pub fn size(mut self, width: u32, height: u32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
fn into_command(self, print_format: &str) -> Command {
let mut command = Command::new("new-session")
.arg("-d")
.arg("-P")
.arg("-F")
.arg(print_format)
.arg("-s")
.arg(self.name.into_os_string());
if let Some(directory) = self.start_directory {
command = command.arg("-c").arg(directory.into_os_string());
}
if let Some(name) = self.window_name {
command = command.arg("-n").arg(name.into_os_string());
}
if let (Some(width), Some(height)) = (self.width, self.height) {
command = command
.arg("-x")
.arg(width.to_string())
.arg("-y")
.arg(height.to_string());
}
for (name, value) in self.environment {
command = command
.arg("-e")
.sensitive_arg(crate::window::assignment(&name, &value));
}
if let Some(shell_command) = self.command {
command = command.arg("--").sensitive_arg(shell_command);
}
command
}
}
impl<T: Into<OsString>> From<T> for NewSessionOptions {
fn from(name: T) -> Self {
let name: OsString = name.into();
Self::new(name)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Chooser {
Tree,
Client,
Buffer,
Customize,
}
impl Chooser {
const fn command(self) -> &'static str {
match self {
Self::Tree => "choose-tree",
Self::Client => "choose-client",
Self::Buffer => "choose-buffer",
Self::Customize => "customize-mode",
}
}
}