Skip to main content

proot/cli/
bwrap.rs

1//! bubblewrap-compatible front end, selected when `argv[0]` ends in `bwrap`.
2//!
3//! bubblewrap describes a container as an ordered list of operations: bind
4//! mounts, directories, symlinks, generated files, environment edits, and
5//! namespace or capability changes.  Without namespaces the description is
6//! materialized instead: a fresh private root directory receives every
7//! `--dir`, `--symlink`, `--tmpfs` and data-file entry, each bind becomes a
8//! [`Binding`] (read-only ones flagged), and the options that only make sense
9//! with namespaces are accepted and ignored.  The result is an ordinary
10//! [`Config`] that the launcher runs like any PRoot invocation.
11//!
12//! Known no-ops: every `--unshare-*`, `--share-net`, `--disable-userns`,
13//! `--assert-userns-disabled`, `--userns*`, `--pidns`, `--uid`, `--gid`,
14//! `--cap-add`, `--cap-drop`, `--as-pid-1`, `--lock-file`, `--sync-fd`,
15//! `--block-fd`, `--userns-block-fd`, `--exec-label`, `--file-label`,
16//! `--mqueue` (created as a plain directory), `--size`, `--level-prefix`,
17//! `--not-a-security-boundary`.  `--hostname` is recorded for a future
18//! `uname` handler.  Overlay options are refused.
19
20use super::CliError;
21use super::host::Host;
22use crate::config::{Binding, Config, trim_slashes};
23use std::collections::BTreeMap;
24use std::ffi::{CString, OsString};
25use std::fs;
26use std::os::unix::ffi::{OsStrExt, OsStringExt};
27use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
28use std::path::{Path, PathBuf};
29
30/// Version string pressure-vessel and flatpak parse from `bwrap --version`.
31pub const BWRAP_VERSION: &str = "bubblewrap 0.9.0 (proot-next)";
32
33const FLAGS: &[&str] = &[
34    "--unshare-all",
35    "--share-net",
36    "--unshare-user",
37    "--unshare-user-try",
38    "--unshare-ipc",
39    "--unshare-pid",
40    "--unshare-net",
41    "--unshare-uts",
42    "--unshare-cgroup",
43    "--unshare-cgroup-try",
44    "--disable-userns",
45    "--assert-userns-disabled",
46    "--clearenv",
47    "--new-session",
48    "--die-with-parent",
49    "--as-pid-1",
50    "--level-prefix",
51    "--not-a-security-boundary",
52    "--help",
53    "--version",
54];
55const ONE_ARG: &[&str] = &[
56    "--args",
57    "--argv0",
58    "--userns",
59    "--userns2",
60    "--pidns",
61    "--uid",
62    "--gid",
63    "--hostname",
64    "--chdir",
65    "--unsetenv",
66    "--lock-file",
67    "--sync-fd",
68    "--remount-ro",
69    "--exec-label",
70    "--file-label",
71    "--proc",
72    "--dev",
73    "--tmpfs",
74    "--mqueue",
75    "--dir",
76    "--seccomp",
77    "--add-seccomp-fd",
78    "--block-fd",
79    "--userns-block-fd",
80    "--info-fd",
81    "--json-status-fd",
82    "--cap-add",
83    "--cap-drop",
84    "--perms",
85    "--size",
86    "--tmp-overlay",
87    "--ro-overlay",
88    "--overlay-src",
89];
90const TWO_ARGS: &[&str] = &[
91    "--setenv",
92    "--bind",
93    "--bind-try",
94    "--dev-bind",
95    "--dev-bind-try",
96    "--ro-bind",
97    "--ro-bind-try",
98    "--bind-fd",
99    "--ro-bind-fd",
100    "--file",
101    "--bind-data",
102    "--ro-bind-data",
103    "--symlink",
104    "--chmod",
105];
106const THREE_ARGS: &[&str] = &["--overlay"];
107
108/// One parsed operation: the option, its arguments, and the `--perms` mode
109/// that applied to it (only for options that create an entry).
110#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct Op {
112    pub option: String,
113    pub args: Vec<Vec<u8>>,
114    pub perms: Option<u32>,
115}
116
117/// Parses bubblewrap-style arguments (without `argv[0]`) into a launchable config.
118///
119/// # Arguments
120/// * `args` - Command-line arguments as bubblewrap would receive them.
121/// * `host` - The process the command line is interpreted for: bind sources
122///   are its paths, descriptors are its descriptors, the environment is its.
123///
124/// # Returns
125/// A [`Config`] whose root is a freshly materialized directory.
126///
127/// # Errors
128/// [`CliError::Exit`] after `--help`/`--version`, [`CliError::Usage`] for
129/// unknown options, missing arguments, unreadable descriptors or overlays.
130pub fn parse(args: Vec<OsString>, host: &dyn Host) -> Result<Config, CliError> {
131    let (ops, command) = parse_ops(args.into_iter().map(|a| a.into_vec()).collect(), host)?;
132    for op in &ops {
133        match op.option.as_str() {
134            "--help" => {
135                return Err(CliError::Exit {
136                    code: 0,
137                    text: format!(
138                        "{BWRAP_VERSION}\n\nusage: bwrap [OPTIONS...] [--] COMMAND [ARGS...]\n\nbubblewrap-compatible front end of proot-next: binds and generated entries are\nmaterialized in a private root and the command runs under ptrace/seccomp\ntranslation instead of namespaces.\n"
139                    ),
140                });
141            }
142            "--version" => {
143                return Err(CliError::Exit {
144                    code: 0,
145                    text: format!("{BWRAP_VERSION}\n"),
146                });
147            }
148            _ => {}
149        }
150    }
151    if command.is_empty() {
152        return Err(CliError::Usage("bwrap: COMMAND is required".into()));
153    }
154    let root = make_root()?;
155    let root_bytes = root.as_os_str().as_bytes().to_vec();
156    let mut cfg = Config {
157        root: root_bytes.clone(),
158        cleanup_dirs: vec![root_bytes],
159        cwd: b"/".to_vec(),
160        ..Config::default()
161    };
162    let mut env: BTreeMap<Vec<u8>, Vec<u8>> = host.vars().into_iter().collect();
163    let mut argv0: Option<Vec<u8>> = None;
164    if let Err(e) = apply_ops(&ops, &root, &mut cfg, &mut env, &mut argv0, host) {
165        let _ = fs::remove_dir_all(&root);
166        return Err(e);
167    }
168    if !ops.iter().any(|op| op.option == "--chdir") {
169        // bubblewrap stays in the caller's working directory where the
170        // sandbox has it (see [`start_dir`]).
171        cfg.cwd = host.cwd();
172        cfg.cwd_default = true;
173    }
174    cfg.env = Some(
175        env.into_iter()
176            .map(|(mut k, v)| {
177                k.push(b'=');
178                k.extend_from_slice(&v);
179                CString::new(k).unwrap_or_default()
180            })
181            .collect(),
182    );
183    cfg.command = command
184        .into_iter()
185        .map(|a| CString::new(a).unwrap_or_default())
186        .collect();
187    cfg.argv0 = argv0.map(|a| CString::new(a).unwrap_or_default());
188    cfg.bwrap_mode = true;
189    Ok(cfg)
190}
191
192/// The directory a launch starts the command in, given which guest paths
193/// are directories of its view: `config.cwd` where the view has it, and in
194/// bwrap mode without `--chdir`, then `$HOME` of the command's environment, as
195/// bubblewrap goes on to.  `None` leaves the command at `/`.
196///
197/// # Arguments
198/// * `config` - The launch, its `cwd` a guest path.
199/// * `is_dir` - Whether a guest path is a directory of the view.
200pub fn start_dir(config: &Config, is_dir: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
201    if is_dir(&config.cwd) {
202        return Some(config.cwd.clone());
203    }
204    if !(config.bwrap_mode && config.cwd_default) {
205        return None;
206    }
207    let home = config.env.as_deref().and_then(|env| var(env, b"HOME"))?;
208    is_dir(&home).then_some(home)
209}
210
211/// `env` with `PWD` naming `dir`, as bubblewrap exports it once it has entered
212/// the directory the command starts in; a program reads its working directory
213/// from it, as Proton's protonfixes does to find the game's library.
214pub fn with_pwd(env: &[CString], dir: &[u8]) -> Vec<CString> {
215    let mut pwd = b"PWD=".to_vec();
216    pwd.extend_from_slice(dir);
217    env.iter()
218        .filter(|e| !e.as_bytes().starts_with(b"PWD="))
219        .cloned()
220        .chain(CString::new(pwd).ok())
221        .collect()
222}
223
224/// The value of `name` in an environment of `NAME=value` entries.
225fn var(env: &[CString], name: &[u8]) -> Option<Vec<u8>> {
226    env.iter().find_map(|e| {
227        let e = e.as_bytes();
228        (e.len() > name.len() && e.starts_with(name) && e[name.len()] == b'=')
229            .then(|| e[name.len() + 1..].to_vec())
230    })
231}
232
233/// Splits the command line into operations and the command.
234///
235/// `--args FD` is spliced in place as bubblewrap does, and `--perms` is
236/// attached to the next entry-creating option.
237///
238/// # Arguments
239/// * `args` - Raw argument bytes, `argv[0]` excluded.
240/// * `host` - The process whose descriptor `--args` names.
241///
242/// # Returns
243/// The operations in order and the command (possibly empty).
244///
245/// # Errors
246/// [`CliError::Usage`] on an unknown option or a missing argument.
247pub fn parse_ops(mut args: Vec<Vec<u8>>, host: &dyn Host) -> Result<(Vec<Op>, Vec<Vec<u8>>), CliError> {
248    let mut ops = Vec::new();
249    let mut perms: Option<u32> = None;
250    let mut i = 0;
251    while i < args.len() {
252        let arg = args[i].clone();
253        if arg == b"--" {
254            i += 1;
255            break;
256        }
257        if !arg.starts_with(b"--") {
258            break;
259        }
260        let name = String::from_utf8_lossy(&arg).into_owned();
261        if name == "--args" {
262            let fd = parse_fd(args.get(i + 1).map(|v| v.as_slice()), "--args")?;
263            let data = host
264                .read_fd(fd)
265                .map_err(|e| CliError::Usage(format!("--args: cannot read fd {fd}: {e}")))?;
266            let spliced: Vec<Vec<u8>> = data
267                .split(|&b| b == 0)
268                .filter(|s| !s.is_empty())
269                .map(|s| s.to_vec())
270                .collect();
271            args.splice(i..i + 2, spliced);
272            continue;
273        }
274        let count = if FLAGS.contains(&name.as_str()) {
275            0
276        } else if ONE_ARG.contains(&name.as_str()) {
277            1
278        } else if TWO_ARGS.contains(&name.as_str()) {
279            2
280        } else if THREE_ARGS.contains(&name.as_str()) {
281            3
282        } else {
283            return Err(CliError::Usage(format!("bwrap: Unknown option {name}")));
284        };
285        if i + count >= args.len() {
286            return Err(CliError::Usage(format!(
287                "bwrap: {name} takes {count} argument(s)"
288            )));
289        }
290        let values: Vec<Vec<u8>> = args[i + 1..i + 1 + count].to_vec();
291        i += 1 + count;
292        if name == "--perms" {
293            let s = String::from_utf8_lossy(&values[0]).into_owned();
294            perms = Some(
295                u32::from_str_radix(&s, 8).map_err(|_| CliError::Usage(format!("bwrap: bad --perms {s}")))?,
296            );
297            continue;
298        }
299        let creates = matches!(
300            name.as_str(),
301            "--dir" | "--tmpfs" | "--symlink" | "--file" | "--bind-data" | "--ro-bind-data" | "--mqueue"
302        );
303        ops.push(Op {
304            option: name,
305            args: values,
306            perms: if creates { perms.take() } else { None },
307        });
308    }
309    Ok((ops, args[i..].to_vec()))
310}
311
312/// Applies the operations: creates entries under `root`, fills bindings and
313/// environment.  Pure mapping lives in [`map_op`]; this does the filesystem work.
314fn apply_ops(
315    ops: &[Op],
316    root: &Path,
317    cfg: &mut Config,
318    env: &mut BTreeMap<Vec<u8>, Vec<u8>>,
319    argv0: &mut Option<Vec<u8>>,
320    host: &dyn Host,
321) -> Result<(), CliError> {
322    for op in ops {
323        let a = |n: usize| -> &[u8] { &op.args[n] };
324        match op.option.as_str() {
325            "--bind" | "--dev-bind" | "--ro-bind" | "--bind-try" | "--dev-bind-try" | "--ro-bind-try" => {
326                let src = a(0);
327                let dest = a(1);
328                if !host.exists(src) {
329                    if op.option.ends_with("-try") {
330                        continue;
331                    }
332                    return Err(CliError::Usage(format!(
333                        "bwrap: Can't find source path {}: No such file or directory",
334                        String::from_utf8_lossy(src)
335                    )));
336                }
337                ensure_mount_point(root, dest, host.is_dir(src));
338                cfg.bindings.push(bind(
339                    &host.host_path(src),
340                    dest,
341                    op.option.starts_with("--ro-bind"),
342                ));
343            }
344            "--bind-fd" | "--ro-bind-fd" => {
345                let fd = parse_fd(Some(a(0)), &op.option)?;
346                let src = host
347                    .fd_path(fd)
348                    .map_err(|e| CliError::Usage(format!("bwrap: {}: fd {fd}: {e}", op.option)))?;
349                ensure_mount_point(root, a(1), Path::new(os(&src)).is_dir());
350                cfg.bindings.push(bind(&src, a(1), op.option == "--ro-bind-fd"));
351            }
352            "--remount-ro" => {
353                let dest = trim_slashes(a(0).to_vec());
354                match cfg.bindings.iter_mut().rev().find(|b| b.guest == dest) {
355                    Some(b) => b.read_only = true,
356                    None => {
357                        let host = under_root(root, &dest);
358                        cfg.bindings.push(Binding {
359                            host,
360                            guest: dest,
361                            read_only: true,
362                        });
363                    }
364                }
365            }
366            "--proc" => {
367                ensure_mount_point(root, a(0), true);
368                cfg.bindings.push(bind(b"/proc", a(0), false));
369            }
370            "--dev" => {
371                ensure_mount_point(root, a(0), true);
372                cfg.bindings.push(bind(b"/dev", a(0), false));
373            }
374            "--dir" | "--tmpfs" | "--mqueue" => {
375                let p = under_root_path(root, a(0));
376                fs::create_dir_all(&p).map_err(|e| usage(&op.option, a(0), e))?;
377                set_mode(&p, op.perms.unwrap_or(0o755));
378            }
379            "--symlink" => {
380                let link = under_root_path(root, a(1));
381                if let Some(parent) = link.parent() {
382                    let _ = fs::create_dir_all(parent);
383                }
384                match fs::symlink_metadata(&link) {
385                    Ok(m)
386                        if m.file_type().is_symlink()
387                            && fs::read_link(&link).ok().as_deref() == Some(Path::new(os(a(0)))) => {}
388                    Ok(_) => {
389                        return Err(CliError::Usage(format!(
390                            "bwrap: Can't make symlink at {}: File exists",
391                            String::from_utf8_lossy(a(1))
392                        )));
393                    }
394                    Err(_) => std::os::unix::fs::symlink(os(a(0)), &link)
395                        .map_err(|e| usage("--symlink", a(1), e))?,
396                }
397            }
398            "--file" | "--bind-data" | "--ro-bind-data" => {
399                let fd = parse_fd(Some(a(0)), &op.option)?;
400                let data = host
401                    .read_fd(fd)
402                    .map_err(|e| CliError::Usage(format!("bwrap: {}: fd {fd}: {e}", op.option)))?;
403                let p = under_root_path(root, a(1));
404                if let Some(parent) = p.parent() {
405                    let _ = fs::create_dir_all(parent);
406                }
407                let mode = op.perms.unwrap_or(0o666 & !umask());
408                fs::OpenOptions::new()
409                    .write(true)
410                    .create(true)
411                    .truncate(true)
412                    .mode(mode)
413                    .open(&p)
414                    .and_then(|mut f| std::io::Write::write_all(&mut f, &data))
415                    .map_err(|e| usage(&op.option, a(1), e))?;
416                set_mode(&p, mode);
417                if op.option == "--ro-bind-data" {
418                    let dest = trim_slashes(a(1).to_vec());
419                    cfg.bindings.push(Binding {
420                        host: p.as_os_str().as_bytes().to_vec(),
421                        guest: dest,
422                        read_only: true,
423                    });
424                }
425            }
426            "--chmod" => {
427                let s = String::from_utf8_lossy(a(0)).into_owned();
428                let mode = u32::from_str_radix(&s, 8)
429                    .map_err(|_| CliError::Usage(format!("bwrap: bad --chmod {s}")))?;
430                set_mode(&under_root_path(root, a(1)), mode);
431            }
432            _ => map_op(op, cfg, env, argv0, host)?,
433        }
434    }
435    Ok(())
436}
437
438/// Applies an operation that touches only the configuration (no filesystem work).
439///
440/// # Arguments
441/// * `op` - Parsed operation.
442/// * `cfg` - Configuration being built.
443/// * `env` - Environment being built for the command.
444/// * `argv0` - Receives `--argv0`.
445/// * `host` - The process whose descriptors `--seccomp` and `--add-seccomp-fd` name.
446///
447/// # Errors
448/// [`CliError::Usage`] for unreadable descriptors and unsupported overlays.
449pub fn map_op(
450    op: &Op,
451    cfg: &mut Config,
452    env: &mut BTreeMap<Vec<u8>, Vec<u8>>,
453    argv0: &mut Option<Vec<u8>>,
454    host: &dyn Host,
455) -> Result<(), CliError> {
456    let a = |n: usize| -> &[u8] { &op.args[n] };
457    match op.option.as_str() {
458        "--chdir" => cfg.cwd = a(0).to_vec(),
459        "--setenv" => {
460            env.insert(a(0).to_vec(), a(1).to_vec());
461        }
462        "--unsetenv" => {
463            env.remove(a(0));
464        }
465        "--clearenv" => env.clear(),
466        "--argv0" => *argv0 = Some(a(0).to_vec()),
467        "--die-with-parent" => cfg.kill_on_exit = true,
468        "--new-session" => cfg.new_session = true,
469        "--hostname" => cfg.hostname = Some(a(0).to_vec()),
470        "--seccomp" | "--add-seccomp-fd" => {
471            let fd = parse_fd(Some(a(0)), &op.option)?;
472            let data = host
473                .read_fd(fd)
474                .map_err(|e| CliError::Usage(format!("bwrap: {}: fd {fd}: {e}", op.option)))?;
475            if op.option == "--seccomp" {
476                cfg.seccomp_programs.clear();
477            }
478            cfg.seccomp_programs.push(data);
479        }
480        "--info-fd" => cfg.info_fds.push(parse_fd(Some(a(0)), "--info-fd")?),
481        "--json-status-fd" => cfg
482            .json_status_fds
483            .push(parse_fd(Some(a(0)), "--json-status-fd")?),
484        "--overlay" | "--tmp-overlay" | "--ro-overlay" | "--overlay-src" => {
485            return Err(CliError::Usage(format!(
486                "bwrap: {} is not supported without namespaces",
487                op.option
488            )));
489        }
490        // Namespaces, capabilities, ids, labels and synchronisation: nothing to do here.
491        _ => {}
492    }
493    Ok(())
494}
495
496/// Creates the private root for this launch.
497///
498/// Lives under `$XDG_RUNTIME_DIR` when writable, else under a `0700`
499/// per-user directory in the temporary directory.
500///
501/// # Errors
502/// [`CliError::Usage`] when no suitable directory can be created.
503fn make_root() -> Result<PathBuf, CliError> {
504    let mut base = match std::env::var_os("XDG_RUNTIME_DIR") {
505        Some(d)
506            if Path::new(&d).is_dir()
507                && fs::metadata(&d)
508                    .map(|m| m.permissions().mode() & 0o200 != 0)
509                    .unwrap_or(false) =>
510        {
511            PathBuf::from(d)
512        }
513        _ => {
514            let d = std::env::temp_dir().join(format!("proot-next-{}", unsafe { libc::getuid() }));
515            let _ = fs::DirBuilder::new().mode(0o700).create(&d);
516            d
517        }
518    };
519    base.push("bwrap");
520    fs::DirBuilder::new()
521        .recursive(true)
522        .mode(0o700)
523        .create(&base)
524        .map_err(|e| CliError::Usage(format!("bwrap: cannot create {}: {e}", base.display())))?;
525    let mut seed = std::time::SystemTime::now()
526        .duration_since(std::time::UNIX_EPOCH)
527        .map(|d| d.as_nanos() as u64)
528        .unwrap_or(0)
529        ^ (std::process::id() as u64).rotate_left(32);
530    for _ in 0..64 {
531        seed ^= seed << 13;
532        seed ^= seed >> 7;
533        seed ^= seed << 17;
534        let root = base.join(format!("{}-{:08x}", std::process::id(), seed as u32));
535        match fs::DirBuilder::new().mode(0o700).create(&root) {
536            Ok(()) => return Ok(root),
537            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
538            Err(e) => {
539                return Err(CliError::Usage(format!(
540                    "bwrap: cannot create {}: {e}",
541                    root.display()
542                )));
543            }
544        }
545    }
546    Err(CliError::Usage(
547        "bwrap: cannot find a free root directory name".into(),
548    ))
549}
550
551/// Creates the mount point for `dest` inside the fresh root, so listings of
552/// the parent show it and lexical path walks find a directory there.
553fn ensure_mount_point(root: &Path, dest: &[u8], is_dir: bool) {
554    let p = under_root_path(root, dest);
555    if p == root {
556        return;
557    }
558    if is_dir {
559        let _ = fs::create_dir_all(&p);
560    } else {
561        if let Some(parent) = p.parent() {
562            let _ = fs::create_dir_all(parent);
563        }
564        let _ = fs::OpenOptions::new()
565            .write(true)
566            .create(true)
567            .truncate(false)
568            .open(&p);
569    }
570}
571
572fn under_root_path(root: &Path, guest: &[u8]) -> PathBuf {
573    let g = trim_slashes(guest.to_vec());
574    if g == b"/" {
575        return root.to_path_buf();
576    }
577    root.join(os(&g[1..]))
578}
579
580fn under_root(root: &Path, guest: &[u8]) -> Vec<u8> {
581    under_root_path(root, guest).as_os_str().as_bytes().to_vec()
582}
583
584fn bind(host: &[u8], guest: &[u8], read_only: bool) -> Binding {
585    Binding {
586        host: trim_slashes(host.to_vec()),
587        guest: trim_slashes(guest.to_vec()),
588        read_only,
589    }
590}
591
592fn os(b: &[u8]) -> &std::ffi::OsStr {
593    std::ffi::OsStr::from_bytes(b)
594}
595
596fn set_mode(p: &Path, mode: u32) {
597    let _ = fs::set_permissions(p, fs::Permissions::from_mode(mode));
598}
599
600fn umask() -> u32 {
601    let m = unsafe { libc::umask(0) };
602    unsafe { libc::umask(m) };
603    m as u32
604}
605
606fn usage(option: &str, path: &[u8], e: std::io::Error) -> CliError {
607    CliError::Usage(format!("bwrap: {option} {}: {e}", String::from_utf8_lossy(path)))
608}
609
610fn parse_fd(v: Option<&[u8]>, option: &str) -> Result<i32, CliError> {
611    let v = v.ok_or_else(|| CliError::Usage(format!("bwrap: {option} takes an argument")))?;
612    String::from_utf8_lossy(v).parse::<i32>().map_err(|_| {
613        CliError::Usage(format!(
614            "bwrap: {option}: not a descriptor: {}",
615            String::from_utf8_lossy(v)
616        ))
617    })
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::cli::host::Local;
624
625    fn v(s: &[&str]) -> Vec<Vec<u8>> {
626        s.iter().map(|x| x.as_bytes().to_vec()).collect()
627    }
628
629    #[test]
630    fn splits_ops_and_command_with_perms() {
631        let (ops, cmd) = parse_ops(
632            v(&[
633                "--perms",
634                "0700",
635                "--dir",
636                "/x",
637                "--ro-bind",
638                "/usr",
639                "/usr",
640                "--",
641                "/bin/sh",
642                "-c",
643                "--die-with-parent",
644            ]),
645            &Local,
646        )
647        .unwrap();
648        assert_eq!(ops.len(), 2);
649        assert_eq!(
650            ops[0],
651            Op {
652                option: "--dir".into(),
653                args: v(&["/x"]),
654                perms: Some(0o700)
655            }
656        );
657        assert_eq!(ops[1].perms, None);
658        assert_eq!(cmd, v(&["/bin/sh", "-c", "--die-with-parent"]));
659    }
660
661    #[test]
662    fn maps_config_only_options() {
663        let mut cfg = Config::default();
664        let mut env = BTreeMap::new();
665        let mut argv0 = None;
666        for op in [
667            Op {
668                option: "--die-with-parent".into(),
669                args: vec![],
670                perms: None,
671            },
672            Op {
673                option: "--new-session".into(),
674                args: vec![],
675                perms: None,
676            },
677            Op {
678                option: "--chdir".into(),
679                args: v(&["/app"]),
680                perms: None,
681            },
682            Op {
683                option: "--setenv".into(),
684                args: v(&["FOO", "bar"]),
685                perms: None,
686            },
687            Op {
688                option: "--unshare-all".into(),
689                args: vec![],
690                perms: None,
691            },
692            Op {
693                option: "--hostname".into(),
694                args: v(&["box"]),
695                perms: None,
696            },
697        ] {
698            map_op(&op, &mut cfg, &mut env, &mut argv0, &Local).unwrap();
699        }
700        assert!(cfg.kill_on_exit && cfg.new_session);
701        assert_eq!(cfg.cwd, b"/app".to_vec());
702        assert_eq!(env.get(b"FOO".as_slice()), Some(&b"bar".to_vec()));
703        assert_eq!(cfg.hostname, Some(b"box".to_vec()));
704    }
705
706    #[test]
707    fn ro_bind_is_read_only_and_root_is_private() {
708        let cfg = parse(
709            vec![
710                "--ro-bind".into(),
711                "/usr".into(),
712                "/usr".into(),
713                "--symlink".into(),
714                "usr/lib".into(),
715                "/lib".into(),
716                "--".into(),
717                "/bin/true".into(),
718            ],
719            &Local,
720        )
721        .unwrap();
722        let b = cfg.bindings.iter().find(|b| b.guest == b"/usr").unwrap();
723        assert!(b.read_only && b.host == b"/usr");
724        let root = Path::new(os(&cfg.root));
725        assert!(root.join("usr").is_dir());
726        assert_eq!(fs::read_link(root.join("lib")).unwrap(), Path::new("usr/lib"));
727        assert_eq!(cfg.cleanup_dirs, vec![cfg.root.clone()]);
728        fs::remove_dir_all(root).unwrap();
729    }
730
731    #[test]
732    fn unknown_option_is_an_error() {
733        assert!(matches!(
734            parse_ops(v(&["--bogus", "x"]), &Local),
735            Err(CliError::Usage(_))
736        ));
737    }
738}