1use 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
30pub 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#[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
117pub 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 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
192pub 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
211pub 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
224fn 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
233pub 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
312fn 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
438pub 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 _ => {}
492 }
493 Ok(())
494}
495
496fn 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
551fn 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}