use std::collections::BTreeSet;
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use lonca_core::canonical::sha256_hex;
use crate::Sandbox;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Jail {
pub workspace: PathBuf,
pub writable: Vec<PathBuf>,
pub readable: Vec<PathBuf>,
pub secrets: Vec<PathBuf>,
pub socket: Option<PathBuf>,
pub proxy_port: Option<u16>,
pub relay: Option<PathBuf>,
pub loopback: bool,
pub mark: Option<String>,
}
const MAC_SYSTEM: [&str; 13] = [
"/System",
"/usr",
"/bin",
"/sbin",
"/opt",
"/private/etc",
"/private/var/select",
"/private/var/db/timezone",
"/private/var/db/dyld",
"/private/var/db/xcode_select_link",
"/Library/Apple",
"/Library/Developer",
"/Library/Frameworks",
];
const MAC_LITERALS: [&str; 11] = [
"/",
"/etc",
"/var",
"/tmp",
"/private",
"/dev",
"/dev/null",
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/dtracehelper",
];
const LINUX_SYSTEM: [&str; 8] = [
"/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc", "/opt",
];
const MACH_SERVICES: [&str; 3] = [
"com.apple.system.opendirectoryd.libinfo",
"com.apple.system.notification_center",
"com.apple.trustd.agent",
];
fn quoted(p: &Path) -> String {
let text = String::from_utf8_lossy(p.as_os_str().as_bytes());
format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
}
fn subpaths<'a>(paths: impl IntoIterator<Item = &'a PathBuf>) -> String {
let each = paths
.into_iter()
.map(|p| format!("(subpath {})", quoted(p)));
each.collect::<Vec<_>>().join(" ")
}
impl Jail {
fn writes(&self) -> impl Iterator<Item = &PathBuf> {
std::iter::once(&self.workspace).chain(&self.writable)
}
fn ancestors(&self) -> BTreeSet<&Path> {
let trees = self.writes().chain(&self.readable);
let trees = trees.chain(&self.socket);
trees.flat_map(|t| t.ancestors().skip(1)).collect()
}
pub fn seatbelt(&self) -> String {
let mut out = String::from(
"(version 1)\n(deny default)\n\
(allow process-fork)\n(allow process-exec*)\n\
(allow process-info* (target same-sandbox))\n\
(allow signal (target same-sandbox))\n\
(allow sysctl-read)\n(allow file-ioctl)\n\
(allow ipc-posix-sem)\n\
(allow ipc-posix-shm-read* ipc-posix-shm-write-data ipc-posix-shm-write-create)\n",
);
for name in MACH_SERVICES {
out += &format!("(allow mach-lookup (global-name \"{name}\"))\n");
}
if let Some(mark) = &self.mark {
let mark = quoted(Path::new(mark));
out += &format!("(allow mach-lookup (global-name {mark}))\n");
}
let system = MAC_SYSTEM.map(PathBuf::from);
out += &format!("(allow file-read* {})\n", subpaths(&system));
let names = MAC_LITERALS.map(|p| format!("(literal \"{p}\")"));
out += &format!("(allow file-read* {})\n", names.join(" "));
for dir in self.ancestors() {
out += &format!("(allow file-read-metadata (literal {}))\n", quoted(dir));
}
let reads = subpaths(self.writes().chain(&self.readable));
out += &format!("(allow file-read* {reads})\n");
let writes = subpaths(self.writes());
out += &format!("(allow file-write* {writes})\n");
out += "(allow file-write-data (literal \"/dev/null\") (literal \"/dev/zero\") \
(literal \"/dev/dtracehelper\"))\n";
if !self.secrets.is_empty() {
let closed = subpaths(&self.secrets);
out += &format!("(deny file-read* file-write* {closed})\n");
}
if let Some(socket) = &self.socket {
let socket = quoted(socket);
out += &format!("(allow network-outbound (literal {socket}))\n");
out += &format!("(allow file-read-metadata (literal {socket}))\n");
}
out += &format!("(allow network-bind network-outbound {writes})\n");
if let Some(port) = self.proxy_port {
out += &format!("(allow network-outbound (remote ip \"localhost:{port}\"))\n");
}
if self.loopback {
out += "(allow network-bind network-inbound network-outbound \
(local ip \"localhost:*\"))\n";
}
out
}
pub fn bwrap_args(&self) -> Vec<OsString> {
let mut out: Vec<OsString> = Vec::new();
let mut flag = |flag: &str, paths: &[&Path]| {
out.push(flag.into());
out.extend(paths.iter().map(|p| p.as_os_str().to_owned()));
};
flag("--unshare-all", &[]);
flag("--die-with-parent", &[]);
flag("--new-session", &[]);
for dir in LINUX_SYSTEM {
flag("--ro-bind-try", &[Path::new(dir), Path::new(dir)]);
}
flag("--dev", &[Path::new("/dev")]);
flag("--proc", &[Path::new("/proc")]);
flag("--tmpfs", &[Path::new("/tmp")]);
for dir in &self.readable {
flag("--ro-bind", &[dir, dir]);
}
for dir in &self.secrets {
flag("--tmpfs", &[dir]);
}
for dir in self.writes() {
flag("--bind", &[dir, dir]);
}
if let Some(socket) = &self.socket {
flag("--bind", &[socket, socket]);
}
if let Some(relay) = &self.relay {
flag("--bind", &[relay, relay]);
}
for dir in &self.secrets {
flag("--remount-ro", &[dir]);
}
flag("--remount-ro", &[Path::new("/")]);
flag("--chdir", &[&self.workspace]);
out
}
pub fn name(&self, sandbox: Sandbox) -> String {
match (sandbox, self.loopback) {
(Sandbox::Seatbelt, true) => format!("{}-loopback", sandbox.name()),
_ => sandbox.name().to_string(),
}
}
pub fn definition(&self, sandbox: Sandbox) -> Option<Vec<u8>> {
match sandbox {
Sandbox::Seatbelt => Some(self.seatbelt().into_bytes()),
Sandbox::Bubblewrap => {
let args = self.bwrap_args();
let bytes = args.iter().map(|a| a.as_bytes());
Some(bytes.collect::<Vec<_>>().join(&0u8))
}
Sandbox::None => None,
}
}
pub fn digest(&self, sandbox: Sandbox) -> Option<String> {
self.definition(sandbox).map(|d| sha256_hex(&d))
}
pub fn command(&self, sandbox: Sandbox, argv: &[OsString]) -> Option<Command> {
let mut c = match sandbox {
Sandbox::Seatbelt => {
let mut c = Command::new("/usr/bin/sandbox-exec");
c.arg("-p").arg(self.seatbelt());
c
}
Sandbox::Bubblewrap => {
let mut c = Command::new(sandbox.program()?);
c.args(self.bwrap_args()).arg("--");
c
}
Sandbox::None => return None,
};
c.args(argv).current_dir(&self.workspace);
Some(c)
}
}
pub fn mark_of(sandbox: Sandbox, what: &str, dir: &Path) -> Option<String> {
let name = dir.file_name().unwrap_or_default().to_string_lossy();
(sandbox == Sandbox::Seatbelt).then(|| format!("lonca.{what}.{name}"))
}
pub fn may_expose(dir: &OsStr, closed: &[PathBuf]) -> bool {
let dir = Path::new(dir);
dir.is_absolute() && !closed.iter().any(|c| c.starts_with(dir))
}
pub fn holding_secrets<'a>(tree: &Path, secrets: &'a [PathBuf]) -> Option<&'a PathBuf> {
secrets
.iter()
.find(|s| tree.starts_with(s) || s.starts_with(tree))
}
pub fn program_trees(
path: &OsStr,
programs: &[PathBuf],
closed: &[PathBuf],
writable: &[PathBuf],
) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let mut add = |dir: Option<PathBuf>| {
let Some(named) = dir else {
return;
};
let Ok(dir) = named.canonicalize() else {
return;
};
let inside = |p: &Path| writable.iter().any(|w| p.starts_with(w));
if !inside(&named)
&& !inside(&dir)
&& may_expose(dir.as_os_str(), closed)
&& !out.contains(&dir)
{
out.push(dir);
}
};
for dir in std::env::split_paths(path) {
add(Some(dir));
}
for program in programs {
add(program.parent().map(Path::to_path_buf));
let real = program.canonicalize().ok();
add(real.and_then(|p| p.parent().map(Path::to_path_buf)));
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn only_seatbelt_marks_a_sandbox_and_the_mark_is_the_runs_alone() {
let dir = Path::new("/scratch/verify-abc");
let marked = mark_of(Sandbox::Seatbelt, "verify", dir);
assert_eq!(marked.as_deref(), Some("lonca.verify.verify-abc"));
assert_eq!(mark_of(Sandbox::Bubblewrap, "verify", dir), None);
assert_eq!(mark_of(Sandbox::None, "verify", dir), None);
}
use super::*;
fn jail() -> Jail {
Jail {
workspace: "/w/ws".into(),
writable: vec!["/w/scratch".into()],
readable: vec!["/h/.local/bin".into()],
secrets: vec!["/h/.lonca".into()],
socket: Some("/h/.lonca/s.sock".into()),
proxy_port: Some(1234),
relay: None,
loopback: false,
mark: None,
}
}
fn at(text: &str, what: &str) -> usize {
text.find(what)
.unwrap_or_else(|| panic!("no {what} in {text}"))
}
#[test]
fn a_seatbelt_profile_refuses_by_default_and_closes_in_order() {
let p = jail().seatbelt();
assert!(p.starts_with("(version 1)\n(deny default)\n"), "{p}");
assert!(!p.contains("(allow default)"), "{p}");
assert!(!p.contains("(allow file-read*)"), "{p}");
assert!(!p.contains("(subpath \"/h\")"), "{p}");
for dir in MAC_SYSTEM {
at(&p, &format!("(subpath \"{dir}\")"));
}
for name in MAC_LITERALS {
at(&p, &format!("(literal \"{name}\")"));
}
assert!(!p.contains("(subpath \"/dev\")"), "{p}");
let open = at(&p, "(allow file-read* (subpath \"/System\")");
let bin = at(
&p,
"(allow file-read* (subpath \"/w/ws\") (subpath \"/w/scratch\") \
(subpath \"/h/.local/bin\"))\n",
);
let store = at(
&p,
"(deny file-read* file-write* (subpath \"/h/.lonca\"))\n",
);
assert!(open < bin && bin < store, "{p}");
assert_eq!(p.matches("(allow file-write* ").count(), 1, "{p}");
at(
&p,
"(allow file-write* (subpath \"/w/ws\") (subpath \"/w/scratch\"))\n",
);
}
#[test]
fn a_seatbelt_profile_has_one_way_out() {
let p = jail().seatbelt();
at(
&p,
"(allow network-outbound (literal \"/h/.lonca/s.sock\"))\n",
);
at(
&p,
"(allow network-outbound (remote ip \"localhost:1234\"))\n",
);
at(
&p,
"(allow network-bind network-outbound (subpath \"/w/ws\") (subpath \"/w/scratch\"))\n",
);
assert_eq!(p.matches("(allow network").count(), 3, "{p}");
let closed = Jail {
proxy_port: None,
..jail()
};
assert!(!closed.seatbelt().contains("remote ip"));
}
#[test]
fn loopback_is_closed_unless_a_human_opened_it_and_then_it_shows() {
let j = jail();
assert!(!j.seatbelt().contains("localhost:*"));
assert_eq!(j.name(Sandbox::Seatbelt), "seatbelt");
let open = Jail {
loopback: true,
mark: None,
..jail()
};
at(
&open.seatbelt(),
"(allow network-bind network-inbound network-outbound (local ip \"localhost:*\"))\n",
);
assert_eq!(open.name(Sandbox::Seatbelt), "seatbelt-loopback");
assert_ne!(j.digest(Sandbox::Seatbelt), open.digest(Sandbox::Seatbelt));
assert_eq!(open.name(Sandbox::Bubblewrap), "bwrap");
assert_eq!(open.bwrap_args(), j.bwrap_args());
}
#[test]
fn a_seatbelt_profile_shows_the_way_to_a_tree_and_nothing_beside_it() {
let p = jail().seatbelt();
for dir in ["/", "/w", "/h", "/h/.local"] {
at(
&p,
&format!("(allow file-read-metadata (literal \"{dir}\"))\n"),
);
}
assert!(!p.contains("(literal \"/h/.local/bin\")"), "{p}");
}
#[test]
fn a_seatbelt_profile_names_only_the_harmless_services() {
let p = jail().seatbelt();
assert_eq!(p.matches("(allow mach-lookup").count(), MACH_SERVICES.len());
let marked = Jail {
mark: Some("lonca.run.x".into()),
..jail()
};
let line = "(allow mach-lookup (global-name \"lonca.run.x\"))\n";
assert!(marked.seatbelt().contains(line));
for name in MACH_SERVICES {
at(&p, &format!("(global-name \"{name}\")"));
}
}
#[test]
fn nothing_closed_writes_no_rule_for_it() {
let open = Jail {
secrets: vec![],
..jail()
};
let p = open.seatbelt();
assert!(!p.contains("(deny file"), "{p}");
}
#[test]
fn a_path_can_not_end_the_string_it_is_quoted_in() {
let evil = Path::new("/w/a\") (allow default) (\"\\");
assert_eq!(quoted(evil), "\"/w/a\\\") (allow default) (\\\"\\\\\"");
}
fn strings(args: &[OsString]) -> Vec<String> {
let each = args.iter().map(|a| a.to_string_lossy().into_owned());
each.collect()
}
#[test]
fn bwrap_has_no_network_and_mounts_in_order() {
let a = strings(&jail().bwrap_args());
let pos = |what: &[&str]| {
a.windows(what.len())
.position(|w| w == what)
.unwrap_or_else(|| panic!("no {what:?} in {a:?}"))
};
pos(&["--unshare-all"]);
pos(&["--die-with-parent"]);
pos(&["--new-session"]);
pos(&["--dev", "/dev"]);
pos(&["--proc", "/proc"]);
pos(&["--chdir", "/w/ws"]);
assert!(!a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]), "{a:?}");
let usr = pos(&["--ro-bind-try", "/usr", "/usr"]);
let tmp = pos(&["--tmpfs", "/tmp"]);
let bin = pos(&["--ro-bind", "/h/.local/bin", "/h/.local/bin"]);
let store = pos(&["--tmpfs", "/h/.lonca"]);
let ws = pos(&["--bind", "/w/ws", "/w/ws"]);
let scratch = pos(&["--bind", "/w/scratch", "/w/scratch"]);
let socket = pos(&["--bind", "/h/.lonca/s.sock", "/h/.lonca/s.sock"]);
assert!(usr < tmp && tmp < bin && bin < store);
assert!(store < ws && ws < scratch && scratch < socket);
assert!(socket < pos(&["--remount-ro", "/h/.lonca"]));
assert!(socket < pos(&["--remount-ro", "/"]));
assert!(!a.iter().any(|x| x == "--share-net"), "{a:?}");
}
#[test]
fn bwrap_takes_the_relay_in_only_when_there_is_one() {
let without = strings(&jail().bwrap_args());
assert!(!without.iter().any(|x| x == "/run/relay.sock"));
let with = Jail {
relay: Some("/run/relay.sock".into()),
..jail()
};
let args = strings(&with.bwrap_args());
let want = ["--bind", "/run/relay.sock", "/run/relay.sock"];
assert!(args.windows(3).any(|w| w == want), "{args:?}");
}
#[test]
fn what_is_signed_is_what_is_run() {
let j = jail();
assert_eq!(
j.definition(Sandbox::Seatbelt).unwrap(),
j.seatbelt().into_bytes()
);
let bwrap = j.definition(Sandbox::Bubblewrap).unwrap();
assert!(bwrap.windows(14).any(|w| w == b"--unshare-all\0"));
assert_eq!(
j.digest(Sandbox::Seatbelt).unwrap(),
sha256_hex(j.seatbelt().as_bytes())
);
let other = Jail {
proxy_port: Some(1),
..jail()
};
assert_ne!(j.digest(Sandbox::Seatbelt), other.digest(Sandbox::Seatbelt));
}
#[test]
fn no_sandbox_means_nothing_runs() {
let j = jail();
assert!(j.definition(Sandbox::None).is_none());
assert!(j.digest(Sandbox::None).is_none());
assert!(j.command(Sandbox::None, &["/bin/sh".into()]).is_none());
}
#[test]
fn a_command_goes_through_the_sandbox_in_the_workspace() {
let j = jail();
let argv = ["/bin/echo".into(), "hi".into()];
let c = j.command(Sandbox::Seatbelt, &argv).unwrap();
assert_eq!(c.get_program(), "/usr/bin/sandbox-exec");
assert_eq!(c.get_current_dir(), Some(Path::new("/w/ws")));
let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy()).collect();
assert_eq!(args[..1], ["-p"]);
assert_eq!(args[1], j.seatbelt());
assert_eq!(args[2..], ["/bin/echo", "hi"]);
}
#[test]
fn a_program_brings_its_directories_and_nothing_that_is_closed() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let home = root.join("home");
let bin = home.join(".local/bin");
let versions = home.join(".local/share/tool/1.0");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir_all(&versions).unwrap();
std::fs::write(versions.join("tool"), "").unwrap();
std::os::unix::fs::symlink(versions.join("tool"), bin.join("tool")).unwrap();
let closed = [home.clone()];
let path = std::env::join_paths([&home, &bin, Path::new("/no/such/dir")]).unwrap();
let got = program_trees(&path, &[bin.join("tool")], &closed, &[]);
assert_eq!(got, [bin.clone(), versions]);
let scratch = root.join("scratch");
std::fs::create_dir(&scratch).unwrap();
std::os::unix::fs::symlink(&bin, scratch.join("bin")).unwrap();
let path = std::env::join_paths([scratch.join("bin")]).unwrap();
assert!(program_trees(&path, &[], &closed, std::slice::from_ref(&scratch)).is_empty());
let path = std::env::join_paths([&bin]).unwrap();
assert!(program_trees(&path, &[], &closed, &[bin]).is_empty());
}
#[test]
fn a_tree_touches_a_secret_from_above_or_from_below() {
let secrets = [PathBuf::from("/h/.lonca"), PathBuf::from("/k")];
assert_eq!(
holding_secrets(Path::new("/h/.lonca/x"), &secrets),
Some(&secrets[0])
);
assert_eq!(
holding_secrets(Path::new("/h"), &secrets),
Some(&secrets[0])
);
assert_eq!(
holding_secrets(Path::new("/k"), &secrets),
Some(&secrets[1])
);
assert_eq!(holding_secrets(Path::new("/h/other"), &secrets), None);
}
#[test]
fn path_entries_that_hold_something_closed_stay_closed() {
let closed = [PathBuf::from("/h"), PathBuf::from("/h/.lonca")];
assert!(may_expose(OsStr::new("/usr/bin"), &closed));
assert!(may_expose(OsStr::new("/h/.cargo/bin"), &closed));
assert!(!may_expose(OsStr::new("/h"), &closed));
assert!(!may_expose(OsStr::new("/"), &closed));
assert!(!may_expose(OsStr::new("relative/bin"), &closed));
}
}