use std::{
borrow::ToOwned,
collections::{HashMap, HashSet},
env,
ffi::{OsStr, OsString},
fs::{self, DirEntry},
io::{self, Write},
os::unix::{fs::symlink, process::CommandExt},
path::{Path, PathBuf},
process,
};
use nix::{
mount::{mount, umount, MsFlags},
sched::{unshare, CloneFlags},
sys::signal::{kill, Signal},
sys::wait::{waitpid, WaitPidFlag, WaitStatus},
unistd::{self, fork, ForkResult},
};
use serde::Deserialize;
mod mkdtemp;
const NONE: Option<&'static [u8]> = None;
fn bind_mount(source: &Path, dest: &Path) {
if let Err(e) = mount(
Some(source),
dest,
Some("none"),
MsFlags::MS_BIND | MsFlags::MS_REC | MsFlags::MS_PRIVATE,
NONE,
) {
log::error!(
"failed to bind mount {} to {}: {}",
source.display(),
dest.display(),
e
);
}
}
#[derive(Debug, Clone, Copy)]
pub enum DirEntryOrExplicitMount<'a> {
DirEntry(&'a DirEntry),
ExplicitMount {
src: &'a Path,
dst_file_name: &'a OsStr,
},
}
impl<'a> From<&'a DirEntry> for DirEntryOrExplicitMount<'a> {
fn from(de: &'a DirEntry) -> DirEntryOrExplicitMount<'a> {
DirEntryOrExplicitMount::DirEntry(de)
}
}
impl<'a> DirEntryOrExplicitMount<'a> {
fn explicit_mount_with_dest_file_name(
mount: &'a Path,
dst_file: &'a (impl AsRef<Path> + 'a),
) -> Self {
let dst_file = dst_file.as_ref();
let dst_file_name = dst_file.file_name().unwrap_or_else(|| {
panic!(
"explicit mount destination `{}` has no file name component \
(must not be `/` or end in `..`)",
dst_file.display()
)
});
DirEntryOrExplicitMount::ExplicitMount {
src: mount,
dst_file_name,
}
}
}
impl DirEntryOrExplicitMount<'_> {
fn file_name(&self) -> OsString {
use DirEntryOrExplicitMount::*;
match self {
DirEntry(d) => d.file_name(),
ExplicitMount { dst_file_name, .. } => (*dst_file_name).to_owned(),
}
}
fn path(&self) -> PathBuf {
use DirEntryOrExplicitMount::*;
match self {
DirEntry(d) => d.path(),
ExplicitMount { src, .. } => (*src).to_owned(),
}
}
fn metadata(&self) -> io::Result<fs::Metadata> {
use DirEntryOrExplicitMount::*;
match self {
DirEntry(d) => d.metadata(),
ExplicitMount { src, .. } => src.symlink_metadata(),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
#[serde(default)]
pub struct PathConfig {
excludes: ExcludePaths,
profile: HashMap<PathBuf, PathBuf>,
absolute: HashMap<PathBuf, PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
#[serde(default)]
pub struct ExcludePaths {
paths: HashSet<PathBuf>,
}
pub struct RunChroot<'a> {
rootdir: &'a Path,
nixdir: &'a Path,
}
impl<'a> RunChroot<'a> {
fn new(rootdir: &'a Path, nixdir: &'a Path) -> Self {
Self { rootdir, nixdir }
}
fn with_rootdir(&self, rootdir: &'a Path) -> Self {
Self {
rootdir,
nixdir: self.nixdir,
}
}
fn resolve_nix_path(
&self,
p: PathBuf,
stop_at_first_non_nix_path: bool,
) -> io::Result<PathBuf> {
self.resolve_nix_path_inner(p, stop_at_first_non_nix_path, 0)
}
fn resolve_nix_path_inner(
&self,
p: PathBuf,
stop_at_first_non_nix_path: bool,
depth: u32,
) -> io::Result<PathBuf> {
const MAX_SYMLINK_DEPTH: u32 = 40;
if depth > MAX_SYMLINK_DEPTH {
return Err(io::Error::other(format!(
"too many levels of symbolic links resolving {}",
p.display()
)));
}
if p.is_symlink() {
let mut target = fs::read_link(&p)?;
if !target.is_absolute() {
target = p.parent().unwrap().join(target);
}
let p = if let Ok(rest) = target.strip_prefix("/nix") {
self.nixdir.join(rest)
} else {
if stop_at_first_non_nix_path {
return Ok(target);
}
target
};
self.resolve_nix_path_inner(p, stop_at_first_non_nix_path, depth + 1)
} else if p.exists() {
Ok(p)
} else {
let mut prefix = PathBuf::new();
let mut components = p.components();
while let Some(c) = components.next() {
prefix.push(c);
if prefix.is_symlink()
&& prefix
.read_link()
.map(|t| t.starts_with("/nix"))
.unwrap_or(false)
{
let actual_parent =
self.resolve_nix_path_inner(prefix, stop_at_first_non_nix_path, depth + 1)?;
let rest: PathBuf = components.collect();
let path = actual_parent.join(rest);
return self.resolve_nix_path_inner(
path,
stop_at_first_non_nix_path,
depth + 1,
);
}
}
Err(io::ErrorKind::NotFound.into())
}
}
fn bind_mount_directory<'p>(&self, entry: impl Into<DirEntryOrExplicitMount<'p>>) {
let entry = entry.into();
let mountpoint = self.rootdir.join(entry.file_name());
if !mountpoint.exists() {
if let Err(e) = fs::create_dir(&mountpoint) {
if e.kind() != io::ErrorKind::AlreadyExists {
panic!("failed to create {}: {}", &mountpoint.display(), e);
}
}
log::info!(
"BIND DIRECTORY {} -> {}",
entry.path().display(),
mountpoint.display()
);
bind_mount(&entry.path(), &mountpoint)
} else {
if mountpoint.is_dir() {
let dir = match fs::read_dir(entry.path()) {
Ok(dir) => dir,
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
log::warn!(
"don't have permission to access directory {}, skipping...",
entry.path().display()
);
return;
}
Err(err) => panic!("failed to list dir {}: {}", entry.path().display(), err),
};
let child = self.with_rootdir(&mountpoint);
for entry in dir {
let entry = entry.expect("error while listing subdir");
child.bind_mount_entry(&entry);
}
}
}
}
fn bind_mount_file<'p>(&self, entry: impl Into<DirEntryOrExplicitMount<'p>>) {
let entry = entry.into();
let mountpoint = self.rootdir.join(entry.file_name());
log::info!(
"BIND FILE {} -> {}",
entry.path().display(),
mountpoint.display()
);
if mountpoint.exists() {
return;
}
fs::File::create(&mountpoint)
.unwrap_or_else(|err| panic!("failed to create {}: {}", &mountpoint.display(), err));
bind_mount(&entry.path(), &mountpoint)
}
fn mirror_symlink<'p>(&self, entry: impl Into<DirEntryOrExplicitMount<'p>>) {
let entry = entry.into();
let link_path = self.rootdir.join(entry.file_name());
if link_path.exists() {
return;
}
let path = entry.path();
let target = self
.resolve_nix_path(path.clone(), true)
.unwrap_or_else(|err| panic!("failed to resolve symlink {}: {}", &path.display(), err));
log::info!(
"MIRROR SYMLINK {} -> {}",
target.display(),
link_path.display()
);
symlink(&target, &link_path).unwrap_or_else(|err| {
panic!(
"failed to create symlink {} -> {} ({err:?})",
&link_path.display(),
&target.display()
)
});
}
fn bind_mount_entry<'p>(&self, entry: impl Into<DirEntryOrExplicitMount<'p>>) {
use DirEntryOrExplicitMount::*;
let mut entry = entry.into();
let adj_path;
let dst_file_name;
if entry.path().starts_with("/nix") {
adj_path = self.resolve_nix_path(entry.path(), true).unwrap();
entry = match entry {
DirEntry(d) => {
dst_file_name = d.file_name();
ExplicitMount {
src: &adj_path,
dst_file_name: &dst_file_name,
}
}
ExplicitMount { dst_file_name, .. } => ExplicitMount {
src: &adj_path,
dst_file_name,
},
};
}
let path = entry.path();
let stat = match entry.metadata() {
Ok(m) => m,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
log::debug!(
"{} disappeared during mirror pass, skipping",
path.display()
);
return;
}
Err(e) => panic!("cannot get stat of {}: {}", path.display(), e),
};
if stat.is_dir() {
self.bind_mount_directory(entry);
} else if stat.is_file() || path == Path::new("/dev/null") {
self.bind_mount_file(entry);
} else if stat.file_type().is_symlink() {
self.mirror_symlink(entry);
} else {
log::debug!(
"skipping special file {} (type {:?})",
path.display(),
stat.file_type()
);
}
}
fn run_chroot(&self, cmd: &str, args: &[String], path_config: Option<PathConfig>) {
let cwd = env::current_dir().expect("cannot get current working directory");
let uid = unistd::getuid();
let gid = unistd::getgid();
unshare(CloneFlags::CLONE_NEWNS | CloneFlags::CLONE_NEWUSER).expect("unshare failed");
mount(
Some(self.rootdir),
self.rootdir,
Some("none"),
MsFlags::MS_BIND | MsFlags::MS_REC,
NONE,
)
.expect("failed to bind mount rootdir to itself");
mount(
Some(self.rootdir),
self.rootdir,
Some("none"),
MsFlags::MS_PRIVATE | MsFlags::MS_REC,
NONE,
)
.expect("failed to remount rootdir as private");
let ogldir = self.nixdir.join("var/nix/opengl-driver/lib");
if ogldir.is_dir() {
let ogl_mount = self.rootdir.join("run/opengl-driver/lib");
fs::create_dir_all(&ogl_mount)
.unwrap_or_else(|err| panic!("failed to create {}: {}", &ogl_mount.display(), err));
bind_mount(&ogldir, &ogl_mount);
}
if let Some(ref c) = path_config {
let user = unistd::User::from_uid(uid).unwrap().unwrap();
let profile_dir = self
.nixdir
.join("var/nix/profiles/per-user")
.join(&user.name)
.join("profile");
let profile_dir = self.resolve_nix_path(profile_dir, false);
let explicit_mounts = c.excludes.paths
.iter()
.map(|ex| (PathBuf::from("/dev/null"), ex))
.chain(
c.profile
.iter()
.filter(|(s, d)| if profile_dir.is_ok() {
true
} else {
log::warn!("couldn't find a profile for user `{}`; skipping profile mount `{}` -> `{}`", &user.name, s.display(), d.display());
false
})
.map(|(prof_p, chroot_p)| {
let prof_p = prof_p.strip_prefix("/").unwrap_or(prof_p);
(profile_dir.as_ref().unwrap().join(prof_p), chroot_p)
})
)
.chain(
c.absolute
.iter()
.inspect(|(src, _)| {
if !src.is_absolute() {
panic!("Explicit mount sources (excluding profile mounts) must be absolute paths! `{}` is not absolute.", src.display())
}
})
.map(|(src, dest)| (src.clone(), dest))
)
.inspect(|(_, dest)| {
if !dest.is_absolute() {
panic!("All explicit mount destinations must be absolute paths! `{}` is not absolute.", dest.display())
}
});
for (src, dest) in explicit_mounts {
if let Ok(src) = self.resolve_nix_path(src.clone(), true) {
log::info!("EXPLICIT {} -> {}", src.display(), dest.display());
let adjusted_dest = dest
.strip_prefix("/") .unwrap()
.parent()
.map(ToOwned::to_owned)
.unwrap_or_default();
let parent = self.rootdir.join(adjusted_dest);
fs::create_dir_all(&parent).unwrap();
let parent = self.with_rootdir(&parent);
parent.bind_mount_entry(
DirEntryOrExplicitMount::explicit_mount_with_dest_file_name(&src, dest),
);
} else {
log::warn!(
"explicit mount source `{}` doesn't seem to exist!",
src.display()
);
}
}
}
let nix_root = PathBuf::from("/");
let dir = fs::read_dir(&nix_root).expect("failed to list / directory");
for entry in dir {
let entry = entry.expect("error while listing from / directory");
if entry.file_name() == "nix" {
continue;
}
self.bind_mount_entry(&entry);
}
if let Some(c) = path_config {
for p in c.excludes.paths.iter() {
let mount = self.rootdir.join(p.strip_prefix("/").unwrap());
log::info!("UNBIND {}", mount.display());
umount(&mount).unwrap();
}
}
let nix_mount = self.rootdir.join("nix");
fs::create_dir(&nix_mount)
.unwrap_or_else(|err| panic!("failed to create {}: {}", &nix_mount.display(), err));
mount(
Some(self.nixdir),
&nix_mount,
Some("none"),
MsFlags::MS_BIND | MsFlags::MS_REC,
NONE,
)
.unwrap_or_else(|err| {
panic!(
"failed to bind mount {} to /nix: {}",
self.nixdir.display(),
err
)
});
unistd::pivot_root(self.rootdir, &nix_mount).unwrap_or_else(|err| {
panic!(
"pivot_root({}, {}): {}",
self.rootdir.display(),
&nix_mount.display(),
err
)
});
let nix_store = nix_root.join(self.nixdir);
mount(
Some(&nix_store),
"/nix",
Some("none"),
MsFlags::MS_BIND | MsFlags::MS_REC,
NONE,
)
.unwrap_or_else(|_| panic!("failed to bind mount {} to /nix", nix_store.display()));
env::set_current_dir("/").expect("cannot change directory to /");
if let Ok(mut file) = fs::File::create("/proc/self/setgroups") {
let _ = file.write_all(b"deny");
}
let mut uid_map =
fs::File::create("/proc/self/uid_map").expect("failed to open /proc/self/uid_map");
uid_map
.write_all(format!("{uid} {uid} 1").as_bytes())
.expect("failed to write new uid mapping to /proc/self/uid_map");
let mut gid_map =
fs::File::create("/proc/self/gid_map").expect("failed to open /proc/self/gid_map");
gid_map
.write_all(format!("{gid} {gid} 1").as_bytes())
.expect("failed to write new gid mapping to /proc/self/gid_map");
env::set_current_dir(&cwd)
.unwrap_or_else(|_| panic!("cannot restore working directory {}", cwd.display()));
let err = process::Command::new(cmd)
.args(args)
.env("NIX_CONF_DIR", "/nix/etc/nix")
.exec();
eprintln!("failed to execute {}: {}", &cmd, err);
process::exit(1);
}
}
fn wait_for_child(rootdir: &Path, child_pid: unistd::Pid) -> ! {
let mut exit_status = 1;
loop {
match waitpid(child_pid, Some(WaitPidFlag::WUNTRACED)) {
Ok(WaitStatus::Signaled(child, Signal::SIGSTOP, _)) => {
let _ = kill(unistd::getpid(), Signal::SIGSTOP);
let _ = kill(child, Signal::SIGCONT);
}
Ok(WaitStatus::Signaled(_, signal, _)) => {
kill(unistd::getpid(), signal).unwrap_or_else(|err| {
panic!("failed to send {signal} signal to our self: {err}")
});
}
Ok(WaitStatus::Exited(_, status)) => {
exit_status = status;
break;
}
Ok(what) => {
eprintln!("unexpected wait event happend: {what:?}");
break;
}
Err(e) => {
eprintln!("waitpid failed: {e}");
break;
}
};
}
fs::remove_dir_all(rootdir)
.unwrap_or_else(|err| panic!("cannot remove tempdir {}: {}", rootdir.display(), err));
process::exit(exit_status);
}
fn main() {
let mut builder = env_logger::Builder::new();
builder
.filter_level(log::LevelFilter::Warn)
.parse_default_env()
.init();
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: {} <nixpath> <command>\n", args[0]);
process::exit(1);
}
let rootdir = mkdtemp::mkdtemp("nix-chroot.XXXXXX")
.unwrap_or_else(|err| panic!("failed to create temporary directory: {err}"));
let nixdir = fs::canonicalize(&args[1])
.unwrap_or_else(|err| panic!("failed to resolve nix directory {}: {}", &args[1], err));
let path_config_file_path = nixdir.join("etc/nix-user-chroot/path-config.toml");
let path_config: Option<PathConfig> = if path_config_file_path.exists() {
let contents = fs::read_to_string(&path_config_file_path).unwrap_or_else(|e| {
eprintln!(
"failed to read config file {}: {}",
path_config_file_path.display(),
e
);
process::exit(1);
});
match toml::from_str(&contents) {
Ok(cfg) => Some(cfg),
Err(e) => {
eprintln!(
"failed to parse config file {}: {}",
path_config_file_path.display(),
e
);
process::exit(1);
}
}
} else {
None
};
if let Some(ref c) = path_config {
for p in &c.excludes.paths {
if !p.is_absolute() {
eprintln!(
"exclude path `{}` must be absolute (in {})",
p.display(),
path_config_file_path.display()
);
process::exit(1);
}
}
}
match unsafe { fork() } {
Ok(ForkResult::Parent { child, .. }) => wait_for_child(&rootdir, child),
Ok(ForkResult::Child) => {
RunChroot::new(&rootdir, &nixdir).run_chroot(&args[2], &args[3..], path_config)
}
Err(e) => {
eprintln!("fork failed: {e}");
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_intermediate_nix_symlink() {
let tmp = mkdtemp::mkdtemp("nix-user-chroot-test.XXXXXX").unwrap();
let nixdir = tmp.join("nixroot");
let real_target = nixdir.join("store/foo/bar");
fs::create_dir_all(real_target.parent().unwrap()).unwrap();
fs::write(&real_target, b"hello").unwrap();
let link = tmp.join("link");
symlink("/nix/store/foo", &link).unwrap();
let rc = RunChroot::new(&tmp, &nixdir);
let input = link.join("bar");
let resolved = rc.resolve_nix_path(input, false).unwrap();
assert_eq!(resolved, real_target);
let link2 = tmp.join("link2");
symlink("/nix/store", &link2).unwrap();
let resolved = rc.resolve_nix_path(link2.join("foo/bar"), false).unwrap();
assert_eq!(resolved, real_target);
fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn resolve_missing_path_no_nix_symlink() {
let tmp = mkdtemp::mkdtemp("nix-user-chroot-test.XXXXXX").unwrap();
let nixdir = tmp.join("nixroot");
fs::create_dir_all(&nixdir).unwrap();
let rc = RunChroot::new(&tmp, &nixdir);
let err = rc
.resolve_nix_path(tmp.join("does/not/exist"), false)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
fs::remove_dir_all(&tmp).unwrap();
}
}