libtmux 0.1.0-alpha.15

Async typed tmux client and object model (alpha)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! The implementation behind the environment methods on servers and sessions.
//!
//! The two differ only in which environment they address, so the reading,
//! which is the part with the awkward cases in it, is written once.

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};

/// Which environment a command addresses.
///
/// tmux keeps these genuinely apart rather than layering one over the other:
/// a name set with `-g` is not visible to `show-environment -t`, which
/// reports it as an unknown variable.
#[derive(Clone, Copy)]
pub(crate) enum Scope<'target> {
    /// The server's own environment, tmux's `-g`, which new sessions copy.
    Global,
    /// One session's environment.
    Session(&'target str),
}

impl Scope<'_> {
    /// Point a command at this environment.
    fn apply(self, command: Command) -> Command {
        match self {
            Self::Global => command.arg("-g"),
            Self::Session(target) => command.arg("-t").arg(target),
        }
    }
}

/// Set a variable, so processes started later inherit it.
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))
            // An environment carries tokens, so the value never reaches a log.
            .sensitive_arg(value),
    )
    .await
}

/// Mark a variable, so processes started later are handed it absent.
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
}

/// Delete a variable, letting whatever tmux inherited show through again.
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
}

/// Read one variable back, telling a removal from a value.
///
/// `None` means tmux does not hold the name at all, which is how a
/// continuation line from a multi-line value is discarded.
///
/// It does not mean the request failed. tmux refuses a name it does not hold
/// with "unknown variable", and refuses a target that is gone with "no such
/// session" -- two different facts on the same exit code. Reading every
/// refusal as the first told a caller their variable was unset when their
/// session had ended, which is the answer they would act on.
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() {
        // "unknown variable: NAME" is the answer `None` exists for. Anything
        // else -- a session that has ended, a server that has gone -- is a
        // failure and must not read as a variable nobody set.
        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(),
    ))))
}

/// Read the whole environment in one command.
///
/// The plain listing cannot be framed: a value containing a newline occupies
/// more than one line, and a continuation line holding an `=` reads as the
/// next variable. `-s` prints each entry as a shell statement instead, with
/// every `"`, `\`, `$` and backtick in the value escaped, so the first
/// unescaped `"` ends the value. That escaping is undone and nothing else is,
/// so each value reads exactly as [`get`] reads it.
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),
        )),
    })
}

/// Parse `show-environment -s`, reporting the entry and byte offset that
/// failed rather than guessing past it.
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..];
        // An exported entry first: its name is printed twice, so the second
        // copy confirms where the first ended. `unset NAME;` carries no such
        // check, and read first it took the start of an exported name such
        // as `unset X;\nY` for a removal of `X`.
        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)
}

/// `unset NAME;` and its newline.
///
/// The first `;` and newline end the name. tmux accepts a name holding that
/// pair, and `unset A;\nunset B;` is then one removal or two: the bytes are
/// the same either way, and this reads two.
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,
    ))
}

/// `NAME="VALUE"; export NAME;` and its newline.
fn parse_exported(rest: &[u8]) -> Option<(String, EnvironmentEntry, usize)> {
    // tmux refuses a name containing `=`, so the first one ends the name.
    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 {
                    // tmux's own rendering of a byte, such as `\033`, which
                    // the plain listing carries too.
                    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(),
    ))
}

/// Parse arbitrary `show-environment -s` output, then check that parsing
/// inverts tmux's rendering.
///
/// The bytes are parsed as a listing first. Then, split at NUL, each chunk is
/// read as one variable -- its first byte's low bit picks set or removed, and
/// the rest splits at its first `=` into name and value -- rendered as
/// `cmd_show_environment_print` writes it, and parsed back.
///
/// # Panics
///
/// When a listing tmux could print does not parse back to its variables.
#[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;
        // tmux refuses an empty name and one holding `=`. It accepts a
        // removed name holding `;` and a newline, which prints exactly as two
        // removed names do, so no parser can tell them apart.
        if name.is_empty() || (!set && name.windows(2).any(|pair| pair == b";\n")) {
            continue;
        }
        variables.insert(name, set.then_some(value));
    }

    // tmux prints in name order, which is this map's.
    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()))
    }

    /// Bytes tmux 3.7d printed. `a$b` arrives as `a\\$b`: `-s` escapes the
    /// `$` for a shell, and tmux escapes it again for display, as the plain
    /// listing that `get` reads does too.
    #[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"));
    }

    /// Found by the `environment_listing` fuzz target: a name tmux accepts,
    /// beginning `unset ` and holding `;` and a newline, failed the whole
    /// listing.
    #[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)
            );
        }
    }
}