use std::collections::BTreeMap;
use std::ffi::OsString;
use crate::error::ListingDecodeError;
use crate::formats::FormatCodecError;
use crate::internal::core::Core;
use crate::internal::listing;
use crate::{Command, EnvironmentEntry, Error, TmuxText};
#[derive(Clone, Copy)]
pub(crate) enum Scope<'target> {
Global,
Session(&'target str),
}
impl Scope<'_> {
fn apply(self, command: Command) -> Command {
match self {
Self::Global => command.arg("-g"),
Self::Session(target) => command.arg("-t").arg(target),
}
}
}
pub(crate) async fn set(
core: &Core,
scope: Scope<'_>,
name: &str,
value: OsString,
) -> Result<(), Error> {
listing::mutate(
core,
"set-environment",
scope
.apply(Command::new("set-environment"))
.arg("--")
.arg(OsString::from(name))
.sensitive_arg(value),
)
.await
}
pub(crate) async fn hide(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), Error> {
listing::mutate(
core,
"set-environment",
scope
.apply(Command::new("set-environment"))
.arg("-r")
.arg("--")
.arg(OsString::from(name)),
)
.await
}
pub(crate) async fn unset(core: &Core, scope: Scope<'_>, name: &str) -> Result<(), Error> {
listing::mutate(
core,
"set-environment",
scope
.apply(Command::new("set-environment"))
.arg("-u")
.arg("--")
.arg(OsString::from(name)),
)
.await
}
pub(crate) async fn get(
core: &Core,
scope: Scope<'_>,
name: &str,
) -> Result<Option<EnvironmentEntry>, Error> {
let result = core
.execute(
scope
.apply(Command::new("show-environment"))
.arg("--")
.arg(OsString::from(name)),
)
.await?;
if !result.success() {
if result
.stderr_lossy()
.trim_start()
.starts_with("unknown variable")
{
return Ok(None);
}
return Err(Error::from_refused_result(
"show-environment",
&result,
None,
));
}
let stdout = result.stdout();
let line = stdout.strip_suffix(b"\n").unwrap_or(stdout);
if line.first() == Some(&b'-') {
return Ok(Some(EnvironmentEntry::Removed));
}
let Some(position) = line.iter().position(|byte| *byte == b'=') else {
return Ok(None);
};
Ok(Some(EnvironmentEntry::Set(TmuxText::from(
line[position + 1..].to_vec(),
))))
}
pub(crate) async fn all(
core: &Core,
scope: Scope<'_>,
) -> Result<BTreeMap<String, EnvironmentEntry>, Error> {
let result = core
.execute(scope.apply(Command::new("show-environment")).arg("-s"))
.await?;
if !result.success() {
return Err(Error::from_refused_result(
"show-environment",
&result,
None,
));
}
parse_shell_listing(result.stdout()).map_err(|(row, offset)| Error::DecodeListing {
list_command: "show-environment",
detail: ListingDecodeError::new(FormatCodecError::row_mismatch(
row,
None,
None,
Some(offset),
)),
})
}
fn parse_shell_listing(
stdout: &[u8],
) -> Result<BTreeMap<String, EnvironmentEntry>, (usize, usize)> {
let mut environment = BTreeMap::new();
let mut at = 0;
let mut row = 0;
while at < stdout.len() {
let rest = &stdout[at..];
let (name, entry, consumed) = parse_exported(rest)
.or_else(|| parse_unset(rest))
.ok_or((row, at))?;
environment.insert(name, entry);
at += consumed;
row += 1;
}
Ok(environment)
}
fn parse_unset(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> {
let body = rest.strip_prefix(b"unset ")?;
let end = body.windows(2).position(|pair| pair == b";\n")?;
let name = &body[..end];
if name.is_empty() || name.contains(&b'=') {
return None;
}
Some((
String::from_utf8_lossy(name).into_owned(),
EnvironmentEntry::Removed,
b"unset ".len() + end + 2,
))
}
fn parse_exported(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> {
let equals = rest.iter().position(|byte| *byte == b'=')?;
let name = &rest[..equals];
if name.is_empty() || rest.get(equals + 1) != Some(&b'"') {
return None;
}
let mut at = equals + 2;
let mut value = Vec::new();
loop {
match *rest.get(at)? {
b'"' => break,
b'\\' => {
let next = *rest.get(at + 1)?;
if matches!(next, b'"' | b'\\' | b'$' | b'`') {
value.push(next);
at += 2;
} else {
value.push(b'\\');
at += 1;
}
}
byte => {
value.push(byte);
at += 1;
}
}
}
at += 1;
let suffix = [b"; export ".as_slice(), name, b";\n"].concat();
if !rest[at..].starts_with(&suffix) {
return None;
}
Some((
String::from_utf8_lossy(name).into_owned(),
EnvironmentEntry::Set(TmuxText::from(value)),
at + suffix.len(),
))
}
#[cfg(feature = "unstable-fuzzing")]
#[doc(hidden)]
pub fn __fuzz_environment_listing(data: &[u8]) {
let _ = parse_shell_listing(data);
let mut variables: BTreeMap<&[u8], Option<&[u8]>> = BTreeMap::new();
for chunk in data.split(|&byte| byte == 0) {
let Some((&kind, rest)) = chunk.split_first() else {
continue;
};
let (name, value) = match rest.iter().position(|&byte| byte == b'=') {
Some(equals) => (&rest[..equals], &rest[equals + 1..]),
None => (rest, &[][..]),
};
let set = kind & 1 == 1;
if name.is_empty() || (!set && name.windows(2).any(|pair| pair == b";\n")) {
continue;
}
variables.insert(name, set.then_some(value));
}
let mut wire = Vec::new();
for (name, value) in &variables {
if let Some(value) = value {
wire.extend_from_slice(name);
wire.extend_from_slice(b"=\"");
for &byte in *value {
if matches!(byte, b'$' | b'`' | b'"' | b'\\') {
wire.push(b'\\');
}
wire.push(byte);
}
wire.extend_from_slice(b"\"; export ");
wire.extend_from_slice(name);
wire.extend_from_slice(b";\n");
} else {
wire.extend_from_slice(b"unset ");
wire.extend_from_slice(name);
wire.extend_from_slice(b";\n");
}
}
let expected: BTreeMap<String, EnvironmentEntry> = variables
.iter()
.map(|(name, value)| {
let entry = match value {
Some(value) => EnvironmentEntry::Set(TmuxText::from(value.to_vec())),
None => EnvironmentEntry::Removed,
};
(String::from_utf8_lossy(name).into_owned(), entry)
})
.collect();
assert_eq!(parse_shell_listing(&wire), Ok(expected), "{wire:?}");
}
#[cfg(test)]
mod tests {
use super::parse_shell_listing;
use crate::{EnvironmentEntry, TmuxText};
fn set(value: &[u8]) -> EnvironmentEntry {
EnvironmentEntry::Set(TmuxText::from(value.to_vec()))
}
#[test]
fn shell_listing_undoes_only_the_shell_escaping() {
let listing = b"V_BS=\"a\\\\b\"; export V_BS;\n\
V_DOLLAR=\"a\\\\$b\"; export V_DOLLAR;\n\
V_DQ=\"a\\\"b\"; export V_DQ;\n\
V_ESC=\"a\\033b\"; export V_ESC;\n\
V_TAB=\"a\tb\"; export V_TAB;\n";
let parsed = parse_shell_listing(listing).expect("listing parses");
assert_eq!(parsed["V_BS"], set(b"a\\b"));
assert_eq!(parsed["V_DOLLAR"], set(b"a\\$b"));
assert_eq!(parsed["V_DQ"], set(b"a\"b"));
assert_eq!(parsed["V_ESC"], set(b"a\\033b"));
assert_eq!(parsed["V_TAB"], set(b"a\tb"));
}
#[test]
fn a_value_shaped_like_framing_stays_one_value() {
let listing = b"MULTI=\"first\nDECOY=x\n\\\"; export X;\nY=\\\"z\"; export MULTI;\n\
unset GONE;\n\
unset A=\"v\"; export unset A;\n";
let parsed = parse_shell_listing(listing).expect("listing parses");
assert_eq!(parsed.len(), 3, "{parsed:?}");
assert_eq!(
parsed["MULTI"],
set(b"first\nDECOY=x\n\"; export X;\nY=\"z")
);
assert_eq!(parsed["GONE"], EnvironmentEntry::Removed);
assert_eq!(parsed["unset A"], set(b"v"));
}
#[test]
fn an_exported_name_shaped_like_a_removal_stays_one_variable() {
let listing = b"unset X;\nY=\"v\"; export unset X;\nY;\n\
unset A;\nB=\"w\"; export B;\n";
let parsed = parse_shell_listing(listing).expect("listing parses");
assert_eq!(parsed.len(), 3, "{parsed:?}");
assert_eq!(parsed["unset X;\nY"], set(b"v"));
assert_eq!(parsed["A"], EnvironmentEntry::Removed);
assert_eq!(parsed["B"], set(b"w"));
}
#[test]
fn unframed_output_is_refused_not_guessed() {
for listing in [
b"PLAIN=value\n".as_slice(),
b"OPEN=\"never closed; export OPEN;\n",
b"MISMATCH=\"v\"; export OTHER;\n",
b"unset ;\n",
b"TRUNCATED=\"v\"; export TRUNCATED;",
] {
assert!(
parse_shell_listing(listing).is_err(),
"accepted {:?}",
String::from_utf8_lossy(listing)
);
}
}
}