use std::ffi::CString;
use std::os::fd::AsRawFd;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use anyhow::{Context, Result, bail};
use crate::landlock::{self, Rights};
pub fn layers(overlay: &crate::Overlay, slot: usize) -> [std::path::PathBuf; 3] {
let command = overlay.directory.join(format!("command-{slot}"));
["upper", "work", "real"].map(|name| command.join(name))
}
fn c_path(path: &Path) -> Result<CString> {
CString::new(path.as_os_str().as_bytes()).context("a path holds a NUL byte")
}
pub struct Setup {
uid_map: CString,
gid_map: CString,
workspace: CString,
real: CString,
options: CString,
aside: std::os::fd::OwnedFd,
passthrough: Vec<CString>,
cwd: CString,
handled: Rights,
outside: Vec<(i32, Rights)>,
workspace_rights: Rights,
inside: Vec<(CString, Rights, bool)>,
}
impl Setup {
pub fn new(overlay: &crate::Overlay, slot: usize, cwd: &Path) -> Result<Self> {
let [upper, work, real] = layers(overlay, slot);
for directory in [&upper, &work, &real] {
std::fs::create_dir_all(directory)
.with_context(|| format!("cannot create {}", directory.display()))?;
}
let text = |path: &Path| -> Result<String> {
let text = path.to_str().context("an overlay's paths must be UTF-8")?;
if text.contains([',', ':', '\\']) {
bail!("an overlay cannot be mounted at {text}");
}
Ok(text.to_owned())
};
let options = format!(
"lowerdir={},upperdir={},workdir={},userxattr",
text(&real)?,
text(&upper)?,
text(&work)?
);
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
let aside: std::os::fd::OwnedFd = std::fs::File::open("/dev/null")
.context("cannot reserve a descriptor")?
.into();
let mut passthrough = Vec::new();
for path in &overlay.passthrough {
let relative = path
.strip_prefix(&overlay.workspace)
.context("a path bound through lies outside the workspace")?;
passthrough.push(
landlock::Open::Directory
.name(relative)
.context("a path holds a NUL byte")?,
);
}
let rules = &overlay.rules;
let mut inside = Vec::new();
for (path, rights, open) in &rules.inside {
let Ok(relative) = path.strip_prefix(&overlay.workspace) else {
continue;
};
let relative = if relative.as_os_str().is_empty() {
Path::new(".")
} else {
relative
};
let name = open.name(relative).context("a path holds a NUL byte")?;
inside.push((name, *rights, *open != landlock::Open::Following));
}
Ok(Self {
aside,
handled: rules.handled,
outside: rules
.outside
.iter()
.map(|(fd, rights)| (fd.as_raw_fd(), *rights))
.collect(),
workspace_rights: rules.workspace,
inside,
uid_map: CString::new(format!("{uid} {uid} 1"))?,
gid_map: CString::new(format!("{gid} {gid} 1"))?,
workspace: c_path(&overlay.workspace)?,
real: c_path(&real)?,
options: CString::new(options)?,
passthrough,
cwd: c_path(cwd)?,
})
}
pub fn enter(&self) -> std::io::Result<()> {
let fail = || Err(std::io::Error::last_os_error());
unsafe {
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
return fail();
}
for (file, text) in [
(c"/proc/self/setgroups", c"deny"),
(c"/proc/self/uid_map", self.uid_map.as_c_str()),
(c"/proc/self/gid_map", self.gid_map.as_c_str()),
] {
let fd = libc::open(file.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
if fd < 0 {
return fail();
}
let bytes = text.to_bytes();
let written = libc::write(fd, bytes.as_ptr().cast(), bytes.len());
libc::close(fd);
if written < 0 {
return fail();
}
}
let none = std::ptr::null();
if libc::mount(
none,
c"/".as_ptr(),
none,
libc::MS_REC | libc::MS_PRIVATE,
none.cast(),
) != 0
|| libc::mount(
self.workspace.as_ptr(),
self.real.as_ptr(),
none,
libc::MS_BIND | libc::MS_REC,
none.cast(),
) != 0
{
return fail();
}
let aside = libc::open(
self.real.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
);
let held = self.aside.as_raw_fd();
if aside < 0 || libc::dup3(aside, held, libc::O_CLOEXEC) < 0 {
return fail();
}
libc::close(aside);
if libc::mount(
c"overlay".as_ptr(),
self.workspace.as_ptr(),
c"overlay".as_ptr(),
0,
self.options.as_ptr().cast(),
) != 0
{
return fail();
}
let merged = libc::open(
self.workspace.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
);
if merged < 0 {
return fail();
}
for relative in &self.passthrough {
let ends = landlock::open_beneath(held, relative)
.and_then(|source| Ok((source, landlock::open_beneath(merged, relative)?)));
let (source, target) = match ends {
Ok(ends) => ends,
Err(error)
if matches!(
error.raw_os_error(),
Some(libc::ENOENT | libc::ELOOP | libc::EXDEV | libc::ENOTDIR)
) =>
{
continue;
}
Err(error) => return Err(error),
};
let (mut source_name, mut target_name) = ([0u8; 32], [0u8; 32]);
if libc::mount(
proc_fd(source.as_raw_fd(), &mut source_name).as_ptr(),
proc_fd(target.as_raw_fd(), &mut target_name).as_ptr(),
none,
libc::MS_BIND | libc::MS_REC,
none.cast(),
) != 0
{
return fail();
}
}
libc::close(merged);
libc::close(held);
if libc::chdir(self.cwd.as_ptr()) != 0 {
return fail();
}
self.restrict()?;
}
Ok(())
}
unsafe fn restrict(&self) -> std::io::Result<()> {
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
let ruleset = landlock::create_ruleset(self.handled)?;
let add = |fd: i32, rights: Rights| {
landlock::add_rule(ruleset.as_raw_fd(), fd, rights, self.handled)
};
for (fd, rights) in &self.outside {
add(*fd, *rights)?;
}
let root = unsafe {
libc::open(
self.workspace.as_ptr(),
libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if root < 0 {
return Err(std::io::Error::last_os_error());
}
let root = unsafe { OwnedFd::from_raw_fd(root) };
add(root.as_raw_fd(), self.workspace_rights)?;
for (relative, rights, unlinked) in &self.inside {
let opened = if *unlinked {
landlock::open_beneath(root.as_raw_fd(), relative)
} else {
let fd = unsafe {
libc::openat(
root.as_raw_fd(),
relative.as_ptr(),
libc::O_PATH | libc::O_CLOEXEC,
)
};
if fd < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
}
};
let fd = match opened {
Ok(fd) => fd,
Err(error)
if matches!(
error.raw_os_error(),
Some(libc::ENOENT | libc::ELOOP | libc::EXDEV | libc::ENOTDIR)
) =>
{
continue;
}
Err(error) => return Err(error),
};
add(fd.as_raw_fd(), *rights)?;
}
drop(root);
landlock::restrict_self(ruleset.as_raw_fd())
}
}
fn proc_fd(fd: i32, buffer: &mut [u8; 32]) -> &std::ffi::CStr {
const PREFIX: &[u8] = b"/proc/self/fd/";
buffer[..PREFIX.len()].copy_from_slice(PREFIX);
let mut digits = [0u8; 10];
let mut count = 0;
let mut rest = fd.unsigned_abs();
loop {
digits[count] = b'0' + (rest % 10) as u8;
count += 1;
rest /= 10;
if rest == 0 {
break;
}
}
for (index, digit) in digits[..count].iter().rev().enumerate() {
buffer[PREFIX.len() + index] = *digit;
}
buffer[PREFIX.len() + count] = 0;
std::ffi::CStr::from_bytes_until_nul(&buffer[..]).unwrap_or(c"/proc/self/fd")
}
pub fn probe(directory: &Path) -> Result<()> {
use std::os::unix::process::CommandExt;
let [lower, upper, work, merged] =
["lower", "upper", "work", "merged"].map(|name| directory.join(name));
for path in [&lower, &upper, &work, &merged] {
std::fs::create_dir_all(path)
.with_context(|| format!("cannot create {}", path.display()))?;
}
let options = CString::new(format!(
"lowerdir={},upperdir={},workdir={},userxattr",
lower.display(),
upper.display(),
work.display()
))?;
let merged_path = c_path(&merged)?;
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
let uid_map = CString::new(format!("{uid} {uid} 1"))?;
let gid_map = CString::new(format!("{gid} {gid} 1"))?;
let mut pipe = [0i32; 2];
if unsafe { libc::pipe2(pipe.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(std::io::Error::last_os_error()).context("cannot make a pipe");
}
let (reader, writer) = (pipe[0], pipe[1]);
let mut command = std::process::Command::new("/bin/sh");
command
.args(["-c", "exit 0"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
unsafe {
command.pre_exec(move || {
let fail = |step: u8| {
let error = std::io::Error::last_os_error();
libc::write(writer, (&step as *const u8).cast(), 1);
Err(error)
};
if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNS) != 0 {
return fail(0);
}
for (step, (file, text)) in [
(c"/proc/self/setgroups", c"deny"),
(c"/proc/self/uid_map", uid_map.as_c_str()),
(c"/proc/self/gid_map", gid_map.as_c_str()),
]
.into_iter()
.enumerate()
{
let fd = libc::open(file.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
if fd < 0 {
return fail(1 + step as u8);
}
let bytes = text.to_bytes();
let written = libc::write(fd, bytes.as_ptr().cast(), bytes.len());
libc::close(fd);
if written < 0 {
return fail(1 + step as u8);
}
}
if libc::mount(
c"overlay".as_ptr(),
merged_path.as_ptr(),
c"overlay".as_ptr(),
0,
options.as_ptr().cast(),
) != 0
{
return fail(4);
}
Ok(())
});
}
let output = command.output();
unsafe { libc::close(writer) };
let mut step = [u8::MAX];
let read = unsafe { libc::read(reader, step.as_mut_ptr().cast(), 1) };
unsafe { libc::close(reader) };
clear(directory);
let what = match (read, step[0]) {
(1, 0) => "making a user and mount namespace",
(1, 1) => "denying setgroups",
(1, 2) => "mapping the user",
(1, 3) => "mapping the group",
(1, 4) => "mounting an overlay",
_ => "running a command over an overlay",
};
let where_ = directory.display();
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => bail!("{what} in {where_} failed: {}", output.status),
Err(error) => Err(error).with_context(|| format!("{what} in {where_} failed")),
}
}
pub fn clear(directory: &Path) {
use std::os::unix::fs::PermissionsExt;
let mut stack = vec![directory.to_owned()];
while let Some(path) = stack.pop() {
if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700));
if let Ok(entries) = std::fs::read_dir(&path) {
stack.extend(entries.flatten().map(|entry| entry.path()));
}
}
}
let _ = std::fs::remove_dir_all(directory);
}
pub fn settle(overlay: &crate::Overlay) -> crate::Settled {
let mut settled = crate::Settled::default();
for slot in 0.. {
let [upper, ..] = layers(overlay, slot);
if !upper.parent().is_some_and(Path::exists) {
break;
}
if upper.is_dir() {
let layer = Layer {
upper: &upper,
root: &overlay.workspace,
keep: &overlay.keep,
passthrough: &overlay.passthrough,
};
layer.apply(Path::new(""), false, &mut settled);
}
}
settled
}
struct Layer<'a> {
upper: &'a Path,
root: &'a Path,
keep: &'a crate::Keep,
passthrough: &'a [std::path::PathBuf],
}
impl Layer<'_> {
fn allowed(&self, target: &Path) -> bool {
self.keep.allows(self.root, target)
}
fn holds_bound(&self, target: &Path) -> bool {
self.passthrough
.iter()
.any(|through| through.starts_with(target))
}
fn holds_kept(&self, target: &Path) -> bool {
self.keep.holds(target)
}
fn names(&self, relative: &Path) -> bool {
let named = |part: &std::ffi::OsStr| self.keep.names.iter().any(|name| part == name);
relative.file_name().is_some_and(named)
&& relative
.parent()
.is_none_or(|parent| !parent.components().any(|part| named(part.as_os_str())))
}
fn apply(&self, relative: &Path, hidden: bool, settled: &mut crate::Settled) -> bool {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
let mut applied = hidden && self.prune(relative, settled);
let entries = match std::fs::read_dir(self.upper.join(relative)) {
Ok(entries) => entries,
Err(error) => {
settled.fail(&self.upper.join(relative), &error);
return applied;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
settled.fail(&self.upper.join(relative), &error);
continue;
}
};
let relative = relative.join(entry.file_name());
let source = self.upper.join(&relative);
let target = self.root.join(&relative);
let metadata = match std::fs::symlink_metadata(&source) {
Ok(metadata) => metadata,
Err(error) => {
settled.fail(&source, &error);
continue;
}
};
let name = relative.to_string_lossy().into_owned();
let kind = metadata.file_type();
let existing = std::fs::symlink_metadata(&target).ok();
let is_dir = existing.as_ref().is_some_and(|existing| existing.is_dir());
let is_link = existing
.as_ref()
.is_some_and(|existing| existing.is_symlink());
if kind.is_char_device() && metadata.rdev() == 0 {
if self.allowed(&target) {
settled.removed(&target);
applied = true;
} else if is_dir && self.holds_kept(&target) {
applied |= self.prune(&relative, settled);
} else {
settled.discarded.insert(name);
}
} else if kind.is_dir() {
let replaces = hidden || opaque(&source);
if self.allowed(&target) {
let prunes = is_dir && replaces && self.holds_bound(&target);
if existing.is_some() && (!is_dir || replaces) && !prunes {
settled.removed(&target);
}
let made = std::fs::create_dir_all(&target).and_then(|()| {
match std::fs::symlink_metadata(&target)?.is_dir() {
true => Ok(()),
false => Err(std::io::Error::other("not a directory")),
}
});
match made {
Ok(()) => {
if !is_dir && self.names(&relative) {
settled.made.push(target.clone());
}
self.apply(&relative, prunes, settled);
let _ = std::fs::set_permissions(&target, metadata.permissions());
applied = true;
}
Err(error) => settled.fail(&target, &error),
}
} else if is_link {
settled.discarded.insert(name);
} else if self.apply(&relative, replaces && is_dir, settled) {
applied = true;
} else if !is_dir {
settled.discarded.insert(name);
}
} else if self.allowed(&target) {
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
if is_dir {
settled.removed(&target);
}
if std::fs::rename(&source, &target).is_err() {
let _ = remove(&target);
let copied = if kind.is_symlink() {
std::fs::read_link(&source)
.and_then(|link| std::os::unix::fs::symlink(link, &target))
} else if kind.is_file() {
std::fs::copy(&source, &target).map(|_| ())
} else {
settled.discarded.insert(name);
continue;
};
if let Err(error) = copied {
settled.fail(&target, &error);
continue;
}
}
applied = true;
} else {
settled.discarded.insert(name);
}
}
applied
}
fn prune(&self, relative: &Path, settled: &mut crate::Settled) -> bool {
let entries = match std::fs::read_dir(self.root.join(relative)) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(error) => {
settled.fail(&self.root.join(relative), &error);
return false;
}
};
let mut removed = false;
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
settled.fail(&self.root.join(relative), &error);
continue;
}
};
let relative = relative.join(entry.file_name());
if std::fs::symlink_metadata(self.upper.join(&relative)).is_ok() {
continue;
}
let target = self.root.join(&relative);
if self
.passthrough
.iter()
.any(|through| target.starts_with(through))
{
continue;
}
let real_dir = entry.file_type().is_ok_and(|kind| kind.is_dir());
if real_dir
&& (self.holds_bound(&target)
|| (!self.allowed(&target) && self.holds_kept(&target)))
{
removed |= self.prune(&relative, settled);
} else if self.allowed(&target) {
settled.removed(&target);
removed = true;
} else {
settled
.discarded
.insert(relative.to_string_lossy().into_owned());
}
}
removed
}
}
impl crate::Settled {
fn fail(&mut self, path: &Path, error: &std::io::Error) {
self.failed.push(format!("{}: {error}", path.display()));
}
fn removed(&mut self, target: &Path) {
if let Err(error) = remove(target)
&& error.kind() != std::io::ErrorKind::NotFound
{
self.fail(target, &error);
}
}
}
fn remove(target: &Path) -> std::io::Result<()> {
match std::fs::symlink_metadata(target)? {
metadata if metadata.is_dir() => std::fs::remove_dir_all(target),
_ => std::fs::remove_file(target),
}
}
fn opaque(directory: &Path) -> bool {
let Ok(path) = c_path(directory) else {
return false;
};
let mut value = [0u8; 2];
let length = unsafe {
libc::lgetxattr(
path.as_ptr(),
c"user.overlay.opaque".as_ptr(),
value.as_mut_ptr().cast(),
value.len(),
)
};
length == 1 && value[0] == b'y'
}
#[cfg(test)]
mod tests {
use super::*;
fn write(upper: &Path, relative: &str) {
let path = upper.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, relative).unwrap();
}
#[test]
fn writes_a_descriptors_path_without_allocating() {
let mut buffer = [0u8; 32];
assert_eq!(proc_fd(7, &mut buffer), c"/proc/self/fd/7");
assert_eq!(
proc_fd(1234567890, &mut buffer),
c"/proc/self/fd/1234567890"
);
}
#[test]
fn binds_no_link_put_in_place_of_a_directory_bound_through() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
if let Err(error) = probe(&root.join("probe")) {
eprintln!("skipped: an overlay cannot be mounted here: {error:#}");
return;
}
let workspace = root.join("workspace");
for directory in ["swapped", "kept", "secret"] {
std::fs::create_dir_all(workspace.join(directory)).unwrap();
}
let Ok(abi) = landlock::abi() else {
eprintln!("skipped: Landlock is not available");
return;
};
let everything: std::os::fd::OwnedFd = std::fs::File::open("/").unwrap().into();
let overlay = crate::Overlay {
workspace: workspace.clone(),
directory: root.join("overlay"),
passthrough: vec![workspace.join("swapped"), workspace.join("kept")],
rules: crate::OverlayRules {
handled: Rights::handled(abi),
outside: vec![(everything, Rights::ALL)],
workspace: Rights::ALL,
inside: Vec::new(),
},
keep: crate::Keep::default(),
settled: std::sync::OnceLock::new(),
};
let setup = Setup::new(&overlay, 0, &workspace).unwrap();
std::fs::remove_dir(workspace.join("swapped")).unwrap();
std::os::unix::fs::symlink("secret", workspace.join("swapped")).unwrap();
let mut command = std::process::Command::new("/bin/sh");
command
.args(["-c", "echo x > swapped/written && echo x > kept/written"])
.current_dir(&workspace);
unsafe {
std::os::unix::process::CommandExt::pre_exec(&mut command, move || setup.enter());
}
let status = command.status().unwrap();
assert!(status.success());
let [upper, ..] = layers(&overlay, 0);
assert!(!workspace.join("secret/written").exists());
assert!(upper.join("secret/written").is_file());
assert!(workspace.join("kept/written").is_file());
clear(&overlay.directory);
}
#[test]
fn keeps_what_is_written_in_any_node_modules() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
for file in [
"lib/node_modules/stale/a",
"app/node_modules/old/a",
"filed/node_modules",
"stubbed",
"elsewhere/kept",
] {
write(&workspace, file);
}
std::fs::create_dir_all(workspace.join("linked")).unwrap();
std::os::unix::fs::symlink(workspace.join("lib"), workspace.join("linked/node_modules"))
.unwrap();
std::os::unix::fs::symlink(workspace.join("elsewhere"), workspace.join("tied")).unwrap();
let overlay = crate::Overlay {
workspace: workspace.clone(),
directory: temp.path().join("overlay"),
passthrough: Vec::new(),
rules: crate::OverlayRules::default(),
keep: crate::Keep {
paths: vec![workspace.join("dist")],
names: vec!["node_modules".into()],
},
settled: std::sync::OnceLock::new(),
};
let [upper, ..] = layers(&overlay, 0);
for file in [
"pkg/node_modules/x/a",
"pkg/stray.js",
"dist/out.js",
"filed/node_modules/x/a",
"linked/node_modules/x/a",
"stubbed/node_modules/x/a",
"tied/node_modules/x/a",
] {
write(&upper, file);
}
std::fs::create_dir_all(upper.join("app/node_modules")).unwrap();
let whiteout = c_path(&upper.join("app/node_modules/old")).unwrap();
let whited = unsafe { libc::mknod(whiteout.as_ptr(), libc::S_IFCHR, 0) } == 0;
write(&upper, "lib/node_modules/fresh/a");
let lib = c_path(&upper.join("lib/node_modules")).unwrap();
let opaque = unsafe {
libc::lsetxattr(
lib.as_ptr(),
c"user.overlay.opaque".as_ptr(),
b"y".as_ptr().cast(),
1,
0,
)
} == 0;
let settled = overlay.settle();
assert_eq!(settled.failed.len(), 1, "{:?}", settled.failed);
assert!(
settled.failed[0].contains("stubbed/node_modules"),
"{:?}",
settled.failed
);
assert_eq!(
settled.discarded,
std::collections::BTreeSet::from(
["pkg/stray.js", "stubbed", "tied"].map(str::to_owned)
)
);
assert!(workspace.join("stubbed").is_file());
assert!(!workspace.join("elsewhere/node_modules").exists());
let mut made = settled.made.clone();
made.sort();
assert_eq!(
made,
[
"filed/node_modules",
"linked/node_modules",
"pkg/node_modules"
]
.map(|path| workspace.join(path))
);
assert!(!workspace.join("lib/x").exists());
for file in [
"pkg/node_modules/x/a",
"dist/out.js",
"lib/node_modules/fresh/a",
"filed/node_modules/x/a",
"linked/node_modules/x/a",
] {
assert!(workspace.join(file).is_file(), "{file}");
}
assert!(!workspace.join("pkg/stray.js").exists());
if whited {
assert!(!workspace.join("app/node_modules/old").exists());
} else {
eprintln!("skipped: a whiteout cannot be made here");
}
if opaque {
assert!(!workspace.join("lib/node_modules/stale").exists());
} else {
eprintln!("skipped: an opaque directory cannot be marked here");
}
}
}