use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use anyhow::{Context, Result, bail};
pub struct Confined {
pub dir: PathBuf,
pub source: Option<PathBuf>,
pub readable: Vec<PathBuf>,
pub network: bool,
pub command: Vec<OsString>,
}
const PASSED_ENV: &[&str] = &["PATH", "TERM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "LOGNAME", "SHELL"];
const PROXY_ENV: &[&str] =
&["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"];
pub fn run(confined: &Confined) -> Result<i32> {
let dir = confined.dir.canonicalize().with_context(|| format!("{}", confined.dir.display()))?;
let source = confined.source.as_deref().and_then(|source| source.canonicalize().ok());
let readable: Vec<PathBuf> = confined.readable.iter().filter_map(|path| path.canonicalize().ok()).collect();
let tmp_root = std::env::temp_dir().canonicalize()?;
for inside in [&dir].into_iter().chain(&source).chain(&readable) {
if tmp_root.starts_with(inside) {
bail!(
"the temporary folder {} is inside {}; set TMPDIR to another folder",
tmp_root.display(),
inside.display()
);
}
}
let tmp = tempfile::Builder::new().prefix("sandfs-shell-").tempdir_in(&tmp_root)?;
let tmp_dir = tmp.path().to_path_buf();
let home = tmp_dir.join("home");
std::fs::create_dir(&home)?;
let mut command = confined.command.clone();
if command.is_empty() {
command.push(std::env::var_os("SHELL").unwrap_or_else(|| "/bin/sh".into()));
}
let places = Places { dir: &dir, tmp: &tmp_dir, source: source.as_deref(), readable: &readable };
let mut cmd = confining(&places, confined.network, &command)?;
cmd.current_dir(&dir).env_clear();
for name in PASSED_ENV {
if let Some(value) = std::env::var_os(name) {
cmd.env(name, value);
}
}
if confined.network {
for name in PROXY_ENV {
match std::env::var_os(name) {
Some(value) if holds_password(&value) => {
eprintln!("sandfs shell: leaving out {name}, which holds a user name or password");
}
Some(value) => {
cmd.env(name, value);
}
None => {}
}
}
}
cmd.env("HOME", &home).env("TMPDIR", &tmp_dir);
cmd.env("GIT_CONFIG_COUNT", "1").env("GIT_CONFIG_KEY_0", "core.fsmonitor").env("GIT_CONFIG_VALUE_0", "false");
let child = cmd.spawn().with_context(|| format!("starting {}", cmd.get_program().to_string_lossy()))?;
#[cfg(target_os = "macos")]
let group = nix::unistd::Pid::from_raw(i32::try_from(child.id())?);
let status = wait(child)?;
#[cfg(target_os = "macos")]
let _ = nix::sys::signal::killpg(group, nix::sys::signal::Signal::SIGKILL);
#[cfg(unix)]
let signalled = std::os::unix::process::ExitStatusExt::signal(&status).map(|signal| 128 + signal);
#[cfg(not(unix))]
let signalled = None;
Ok(status.code().or(signalled).unwrap_or(1))
}
fn holds_password(value: &std::ffi::OsStr) -> bool {
let value = value.to_string_lossy();
let rest = value.split_once("://").map_or(&*value, |(_, rest)| rest);
rest.split(['/', '?', '#']).next().is_some_and(|authority| authority.contains('@'))
}
struct Places<'a> {
dir: &'a Path,
tmp: &'a Path,
source: Option<&'a Path>,
readable: &'a [PathBuf],
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn new_session() -> bool {
let tiocsti_refused = std::fs::read_to_string("/proc/sys/dev/tty/legacy_tiocsti").is_ok_and(|on| on.trim() == "0");
!(tiocsti_refused && std::io::IsTerminal::is_terminal(&std::io::stdin()))
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const LINUX_ETC: &[&str] = &[
"/etc/ld.so.cache",
"/etc/ld.so.conf",
"/etc/ld.so.conf.d",
"/etc/alternatives",
"/etc/passwd",
"/etc/group",
"/etc/nsswitch.conf",
"/etc/localtime",
"/etc/timezone",
"/etc/profile",
"/etc/profile.d",
"/etc/bash.bashrc",
"/etc/inputrc",
"/etc/zsh",
"/etc/terminfo",
"/etc/mime.types",
"/etc/gitconfig",
"/etc/ssl/certs",
"/etc/ssl/openssl.cnf",
"/etc/ca-certificates",
"/etc/ca-certificates.conf",
"/etc/pki/ca-trust/extracted",
"/etc/pki/tls/certs",
"/etc/pki/tls/cert.pem",
"/etc/pki/tls/openssl.cnf",
];
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const LINUX_ETC_NETWORK: &[&str] = &["/etc/resolv.conf", "/etc/hosts", "/etc/host.conf", "/etc/gai.conf"];
#[cfg(target_os = "linux")]
fn confining(places: &Places, network: bool, command: &[OsString]) -> Result<Command> {
let bwrap = ["/usr/bin", "/bin", "/usr/local/bin", "/run/current-system/sw/bin"]
.map(|dir| Path::new(dir).join("bwrap"))
.into_iter()
.find(|path| path.is_file())
.context("sandfs shell needs bubblewrap: install the bubblewrap package")?;
let mut cmd = Command::new(bwrap);
cmd.args(["--unshare-all", "--die-with-parent"]);
if network {
cmd.arg("--share-net");
}
if new_session() {
cmd.arg("--new-session");
}
let mut shown = vec!["/usr"];
cmd.args(["--ro-bind", "/usr", "/usr"]);
for top in ["/bin", "/sbin", "/lib", "/lib32", "/lib64", "/libx32"] {
match std::fs::read_link(top) {
Ok(target) => {
cmd.arg("--symlink").arg(target).arg(top);
}
Err(_) => {
cmd.args(["--ro-bind-try", top, top]);
shown.push(top);
}
}
}
let etc = LINUX_ETC.iter().chain(if network { LINUX_ETC_NETWORK } else { &[] });
for &path in etc {
cmd.args(["--ro-bind-try", path, path]);
shown.push(path);
}
if let Some(source) = places.source {
if let Some(held) = shown.iter().find(|shown| Path::new(shown).starts_with(source)) {
bail!("sandfs shell can't hide the source {} from the shell: it holds {held}", source.display());
}
if shown.iter().any(|shown| source.starts_with(shown)) {
cmd.arg("--tmpfs").arg(source);
}
}
cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]);
for path in places.readable {
cmd.arg("--ro-bind").arg(path).arg(path);
}
cmd.arg("--bind").arg(places.dir).arg(places.dir);
cmd.arg("--bind").arg(places.tmp).arg(places.tmp);
cmd.arg("--chdir").arg(places.dir).arg("--").args(command);
Ok(cmd)
}
#[cfg(target_os = "macos")]
fn confining(places: &Places, network: bool, command: &[OsString]) -> Result<Command> {
let profile = places.tmp.join("confine.sb");
std::fs::write(&profile, macos_profile(places, network)?)?;
let mut cmd = crate::relaunch::command()?;
cmd.args(["shell-exec", "--", "/usr/bin/sandbox-exec", "-f"]).arg(profile).args(command);
Ok(cmd)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn confining(_: &Places, _: bool, _: &[OsString]) -> Result<Command> {
bail!("sandfs shell runs on Linux and macOS")
}
#[cfg(unix)]
pub fn exec_in_new_session(command: &[OsString]) -> Result<()> {
use std::os::unix::process::CommandExt;
let Some((program, args)) = command.split_first() else { bail!("nothing to run") };
nix::unistd::setsid()?;
let err = Command::new(program).args(args).exec();
Err(err).with_context(|| format!("starting {}", program.to_string_lossy()))
}
#[cfg(not(unix))]
pub fn exec_in_new_session(_: &[OsString]) -> Result<()> {
bail!("sandfs shell runs on Linux and macOS")
}
#[cfg(not(all(target_os = "macos", mount)))]
fn wait(mut child: std::process::Child) -> Result<ExitStatus> {
Ok(child.wait()?)
}
#[cfg(all(target_os = "macos", mount))]
fn wait(mut child: std::process::Child) -> Result<ExitStatus> {
use nix::sys::signal::{Signal, killpg};
use tokio::signal::unix::{SignalKind, signal};
let group = nix::unistd::Pid::from_raw(i32::try_from(child.id())?);
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
runtime.block_on(async move {
let mut interrupt = signal(SignalKind::interrupt())?;
let mut terminate = signal(SignalKind::terminate())?;
let mut hangup = signal(SignalKind::hangup())?;
let mut quit = signal(SignalKind::quit())?;
let mut resize = signal(SignalKind::window_change())?;
let mut stop = signal(SignalKind::from_raw(Signal::SIGTSTP as i32))?;
let mut exited = tokio::task::spawn_blocking(move || child.wait());
loop {
let passed = tokio::select! {
status = &mut exited => return Ok(status??),
_ = interrupt.recv() => Signal::SIGINT,
_ = terminate.recv() => Signal::SIGTERM,
_ = hangup.recv() => Signal::SIGHUP,
_ = quit.recv() => Signal::SIGQUIT,
_ = resize.recv() => Signal::SIGWINCH,
_ = stop.recv() => {
let _ = killpg(group, Signal::SIGSTOP);
let _ = nix::sys::signal::raise(Signal::SIGSTOP);
Signal::SIGCONT
}
};
let _ = killpg(group, passed);
}
})
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn macos_profile(places: &Places, network: bool) -> Result<String> {
let mut above: Vec<String> = Vec::new();
for path in [places.dir, places.tmp].into_iter().chain(places.readable.iter().map(PathBuf::as_path)) {
for ancestor in path.ancestors().skip(1) {
above.push(quoted(ancestor)?);
}
}
above.sort();
above.dedup();
let above: String = above.iter().map(|path| format!("\n (literal {path})")).collect();
let hidden = match places.source {
Some(source) => format!("(deny file-read* file-write* (subpath {}))", quoted(source)?),
None => String::new(),
};
let mut readable = String::new();
for path in places.readable {
readable.push_str(&format!("(allow file-read* (subpath {}))\n", quoted(path)?));
}
let (dir, tmp) = (quoted(places.dir)?, quoted(places.tmp)?);
let network = if network { NETWORK_ON } else { "(deny network*)" };
Ok(fill(
MACOS_PROFILE,
&[
("@ABOVE@", &above),
("@HIDDEN@", &hidden),
("@READABLE@", &readable),
("@DIR@", &dir),
("@TMP@", &tmp),
("@NETWORK@", network),
],
))
}
fn fill(template: &str, values: &[(&str, &str)]) -> String {
let mut filled = String::with_capacity(template.len());
let mut rest = template;
while let Some(at) = rest.find('@') {
filled.push_str(&rest[..at]);
rest = &rest[at..];
match values.iter().find(|(key, _)| rest.starts_with(key)) {
Some((key, value)) => {
filled.push_str(value);
rest = &rest[key.len()..];
}
None => {
filled.push('@');
rest = &rest[1..];
}
}
}
filled.push_str(rest);
filled
}
fn quoted(path: &Path) -> Result<String> {
let Some(path) = path.to_str() else { bail!("{} is not valid UTF-8", path.display()) };
if path.contains(['"', '\\', '\n']) {
bail!("sandfs shell can't confine to {path}: rename it without quotes, backslashes or newlines");
}
Ok(format!("\"{path}\""))
}
const MACOS_PROFILE: &str = r#"(version 1)
(deny default)
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow sysctl-read)
;; Except other processes' arguments and environments, sandfs's own among them: the
;; caller's tokens are in it.
(deny sysctl-read (sysctl-name-prefix "kern.procargs"))
(allow user-preference-read
(preference-domain "kCFPreferencesAnyApplication")
(preference-domain "com.apple.dt.Xcode"))
(allow pseudo-tty)
(allow file-read-metadata
(literal "/")
(literal "/var")
(literal "/etc")
(literal "/tmp")
(literal "/private")
(literal "/private/var")
(literal "/private/var/db")
(literal "/private/var/select")
(literal "/Applications")
(literal "/Library")
(literal "/Library/Preferences")
(literal "/Library/Developer")@ABOVE@)
;; dyld opens "/" as it starts.
(allow file-read-data (literal "/"))
(allow file-read*
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/System")
(subpath "/Library/Developer/CommandLineTools")
(subpath "/opt/homebrew")
(subpath "/private/etc")
(subpath "/private/var/db/dyld")
(subpath "/private/var/db/timezone")
(literal "/private/var/select/sh")
(subpath "/dev")
(literal "/private/var/db/xcode_select_link")
(subpath "/Applications/Xcode.app")
(literal "/Library/Preferences/com.apple.dt.Xcode.plist"))
;; /System/Volumes/Data is every other folder again under another name (/Users among
;; them), the disks hold every file, Homebrew's var (here or, on Intel Macs, under
;; /usr/local) holds its services' data, and /etc/ssh the machine's own keys. Homebrew's
;; etc stays readable like /etc: its programs fail on settings files they can't read.
(deny file-read* file-write*
(subpath "/System/Volumes/Data")
(regex #"^/dev/r?disk")
(subpath "/opt/homebrew/var")
(subpath "/usr/local/var")
(regex #"^/private/etc/ssh/ssh_host_")
(literal "/private/etc/master.passwd")
(literal "/private/etc/sudoers")
(subpath "/private/etc/sudoers.d"))
(allow file-map-executable
(subpath "/usr")
(subpath "/bin")
(subpath "/sbin")
(subpath "/System")
(subpath "/Library/Developer/CommandLineTools")
(subpath "/Applications/Xcode.app")
(subpath "/opt/homebrew"))
(allow file-write-data
(literal "/dev/null")
(literal "/dev/tty"))
@HIDDEN@
@READABLE@(allow file-read* file-write*
(subpath @DIR@)
(subpath @TMP@))
;; Programs built in the workspace load their own libraries and plugins.
(allow file-map-executable
(subpath @DIR@)
(subpath @TMP@))
;; Without these `ls -l` is several times slower: notifications and user names.
(allow ipc-posix-shm-read-data (ipc-posix-name "apple.shm.notification_center"))
(allow mach-lookup
(global-name "com.apple.system.notification_center")
(global-name "com.apple.system.opendirectoryd.libinfo"))
@NETWORK@
"#;
const NETWORK_ON: &str = r#"(allow network*)
(allow file-read* (literal "/private/var/run/mDNSResponder"))
(allow mach-lookup
(global-name "com.apple.dnssd.service")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.networkd")
(global-name "com.apple.nehelper"))"#;
#[cfg(test)]
mod tests {
use super::*;
fn places<'a>(dir: &'a str, tmp: &'a str, source: Option<&'a str>) -> Places<'a> {
Places { dir: Path::new(dir), tmp: Path::new(tmp), source: source.map(Path::new), readable: &[] }
}
#[test]
fn the_macos_profile_names_the_mount_and_the_folders_above_it() {
let profile = macos_profile(&places("/Users/ann/ws", "/private/var/folders/x/T/s", None), false).unwrap();
assert!(profile.contains("(subpath \"/Users/ann/ws\")\n (subpath \"/private/var/folders/x/T/s\")"));
for above in ["/Users", "/Users/ann", "/private/var/folders", "/private/var/folders/x/T"] {
assert!(profile.contains(&format!("(literal \"{above}\")")), "{above}");
}
assert!(!profile.contains("(literal \"/Users/ann/ws\")"));
assert!(profile.contains("(deny network*)") && !profile.contains("(allow network*)"));
assert_eq!(profile.matches("(deny file-read*").count(), 1, "only the data volume is hidden");
for placeholder in ["@ABOVE@", "@HIDDEN@", "@READABLE@", "@DIR@", "@TMP@", "@NETWORK@"] {
assert!(!profile.contains(placeholder), "{placeholder} is filled");
}
let open = macos_profile(&places("/w", "/t", None), true).unwrap();
assert!(open.contains("(allow network*)") && !open.contains("(deny network*)"));
assert!(!open.contains("SecurityServer"), "no keychain");
assert!(profile.contains("(deny sysctl-read (sysctl-name-prefix \"kern.procargs\"))"));
assert!(macos_profile(&places("/a\"b", "/t", None), false).is_err());
}
#[test]
fn the_macos_profile_hides_the_source_before_allowing_the_mount() {
let profile = macos_profile(&places("/data/ws", "/t", Some("/usr/local/share/books")), false).unwrap();
let hidden = profile.find("(deny file-read* file-write* (subpath \"/usr/local/share/books\"))").unwrap();
assert!(hidden < profile.find("(subpath \"/data/ws\")").unwrap(), "the mount's rule comes later and wins");
}
#[test]
fn the_macos_profile_shows_readable_folders_inside_the_hidden_source() {
let readable = [PathBuf::from("/Users/ann/.sandfs/repos/r/objects")];
let mut places = places("/Users/ann/ws", "/t", Some("/Users/ann/.sandfs/repos/r"));
places.readable = &readable;
let profile = macos_profile(&places, false).unwrap();
let hidden = profile.find("(deny file-read* file-write* (subpath \"/Users/ann/.sandfs/repos/r\"))").unwrap();
let shown = profile.find("(allow file-read* (subpath \"/Users/ann/.sandfs/repos/r/objects\"))").unwrap();
assert!(hidden < shown, "the later rule wins");
assert!(profile.contains("(literal \"/Users/ann/.sandfs/repos/r\")"));
}
#[test]
fn the_macos_profile_hides_the_data_volume_but_not_a_mount_on_it() {
let profile = macos_profile(&places("/System/Volumes/Data/Users/ann/ws", "/t", None), false).unwrap();
let system = profile.find("(subpath \"/System\")\n").unwrap();
let data = profile.find("(subpath \"/System/Volumes/Data\")").unwrap();
let mount = profile.find("(subpath \"/System/Volumes/Data/Users/ann/ws\")").unwrap();
assert!(system < data && data < mount, "each later rule wins");
}
#[test]
fn proxies_with_passwords_are_recognized() {
for value in ["http://u:p@proxy:3128", "u@proxy:3128", "socks5://u@h/"] {
assert!(holds_password(value.as_ref()), "{value}");
}
for value in ["http://proxy:3128", "proxy:3128", "http://proxy/a@b", "localhost,.example.com"] {
assert!(!holds_password(value.as_ref()), "{value}");
}
}
#[test]
fn placeholders_in_paths_stay_as_they_are() {
let profile = macos_profile(&places("/Users/a/@TMP@/ws", "/t", None), false).unwrap();
assert!(profile.contains("(subpath \"/Users/a/@TMP@/ws\")"));
assert_eq!(fill("@A@-@B@ x@y", &[("@A@", "@B@"), ("@B@", "b")]), "@B@-b x@y");
}
}