use std::{
env,
ffi::CStr,
fs::{self, File, OpenOptions},
io,
os::unix::{
ffi::OsStrExt,
fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
},
path::{Path, PathBuf},
};
use crate::{Error, ErrorKind, Result};
#[cfg(target_os = "macos")]
const STICKY_BIT: u32 = libc::S_ISVTX as u32;
#[cfg(not(target_os = "macos"))]
const STICKY_BIT: u32 = libc::S_ISVTX;
pub fn user_namespace() -> Result<String> {
// The effective identity, not a user-controlled environment variable, owns
// both the cache and the bytes subsequently passed to dlopen.
Ok(format!("swc-native-{}", unsafe { libc::geteuid() }))
}
/// Prefer a per-user location: hardened Linux installations commonly mount the
/// system temporary directory `noexec`, preventing native addon loading. The
/// temporary directory is a fallback only and must pass the same checks.
pub fn user_cache_root() -> Result<PathBuf> {
let root = env::var_os("XDG_CACHE_HOME")
.filter(|value| Path::new(value).is_absolute())
.or_else(|| {
env::var_os("HOME").map(|home| {
if cfg!(target_os = "macos") {
PathBuf::from(home).join("Library/Caches")
} else {
PathBuf::from(home).join(".cache")
}
.into_os_string()
})
})
.map(PathBuf::from)
.ok_or_else(|| Error::new(ErrorKind::Cache, "cannot determine native addon user cache"))?;
executable_cache_root(&root)?;
Ok(root)
}
/// Resolve aliases such as macOS's /var before validating fallback ancestors.
/// The caller still checks mount execution policy and directory security.
pub(crate) fn fallback_cache_root() -> Result<PathBuf> {
fs::canonicalize(env::temp_dir())
.map_err(|e| Error::io(ErrorKind::Cache, "resolve native addon fallback cache", e))
}
/// Reject roots from which the dynamic loader cannot map a materialized addon.
/// This applies to explicit custom roots as well as the user default.
pub fn executable_cache_root(root: &Path) -> Result<()> {
if noexec_mount(root)? {
return Err(Error::new(
ErrorKind::Cache,
format!(
"native addon user cache {} is mounted noexec",
root.display(),
),
));
}
Ok(())
}
/// A cache root and every ancestor must be stable after validation. A
/// world-writable sticky directory such as /tmp is safe only when it belongs
/// to the effective user or root. A sticky directory owned by another user
/// still lets that owner rename our private namespace after validation.
/// Trusted environments may explicitly skip this ancestor validation with
/// `SWC_NATIVE_BINDING_CACHE_SKIP_SECURITY_CHECK=1`. Absolute paths, private
/// cache entries, and executable-mount requirements are still enforced.
pub fn secure_cache_root(root: &Path) -> io::Result<()> {
if !root.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"cache root must be absolute",
));
}
// Some isolated CI executors cannot satisfy ancestor ownership or mode
// requirements. This opt-out also permits symlinked ancestors, so callers
// must trust every component against replacement before the native load.
if env::var_os("SWC_NATIVE_BINDING_CACHE_SKIP_SECURITY_CHECK").as_deref()
== Some(std::ffi::OsStr::new("1"))
{
tracing::debug!(path = %root.display(), "skipping native cache root security check by explicit environment opt-out");
return Ok(());
}
for directory in root.ancestors() {
let metadata = fs::symlink_metadata(directory)?;
if !metadata.is_dir() || metadata.file_type().is_symlink() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"cache root contains a non-directory or symlink",
));
}
if !cache_ancestor_is_secure(metadata.mode(), metadata.uid(), unsafe { libc::geteuid() }) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"cache root has a parent writable by another user without trusted sticky \
protection",
));
}
}
Ok(())
}
fn cache_ancestor_is_secure(mode: u32, owner: u32, current: u32) -> bool {
if owner != current && owner != 0 {
// Even a mode-0555 directory owner can chmod it, rename our validated
// namespace, and replace the addon before dlopen reopens its path.
return false;
}
mode & 0o022 == 0 || mode & STICKY_BIT != 0
}
#[cfg(target_os = "linux")]
fn noexec_mount(path: &Path) -> Result<bool> {
let mut existing = path;
while !existing.exists() {
existing = existing
.parent()
.ok_or_else(|| Error::new(ErrorKind::Cache, "cache root has no existing ancestor"))?;
}
let path = fs::canonicalize(existing)
.map_err(|e| Error::io(ErrorKind::Cache, "resolve native addon cache", e))?;
let mounts = fs::read_to_string("/proc/self/mountinfo")
.map_err(|e| Error::io(ErrorKind::Cache, "read Linux mount table", e))?;
Ok(noexec_mount_in(&path, &mounts))
}
#[cfg(target_os = "linux")]
fn noexec_mount_in(path: &Path, mounts: &str) -> bool {
let mut selected: Option<(PathBuf, bool)> = None;
for line in mounts.lines() {
let Some((before, _)) = line.split_once(" - ") else {
continue;
};
let fields: Vec<_> = before.split_whitespace().collect();
if fields.len() < 6 {
continue;
}
let mount = PathBuf::from(fields[4].replace("\\040", " ").replace("\\011", "\t"));
if path.starts_with(&mount)
&& selected.as_ref().map_or(true, |(current, _)| {
mount.as_os_str().len() > current.as_os_str().len()
})
{
selected = Some((mount, fields[5].split(',').any(|option| option == "noexec")));
}
}
selected.is_some_and(|(_, noexec)| noexec)
}
#[cfg(not(target_os = "linux"))]
#[cfg(not(target_os = "macos"))]
fn noexec_mount(_path: &Path) -> Result<bool> {
Ok(false)
}
#[cfg(target_os = "macos")]
fn noexec_mount(path: &Path) -> Result<bool> {
use std::os::fd::AsRawFd;
let mut existing = path;
while !existing.exists() {
existing = existing
.parent()
.ok_or_else(|| Error::new(ErrorKind::Cache, "cache root has no existing ancestor"))?;
}
let file = File::open(existing)
.map_err(|e| Error::io(ErrorKind::Cache, "open native addon cache mount", e))?;
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
if unsafe { libc::fstatfs(file.as_raw_fd(), &mut stat) } != 0 {
return Err(Error::io(
ErrorKind::Cache,
"inspect native addon cache mount",
io::Error::last_os_error(),
));
}
Ok(mount_is_noexec(stat.f_flags))
}
#[cfg(target_os = "macos")]
fn mount_is_noexec(flags: u32) -> bool {
flags & libc::MNT_NOEXEC as u32 != 0
}
pub fn private_directory(path: &Path) -> io::Result<()> {
match fs::DirBuilder::new().mode(0o700).create(path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_dir()
|| metadata.uid() != unsafe { libc::geteuid() }
|| metadata.mode() & 0o077 != 0
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"cache directory must be a real directory owned by the current user with mode 0700",
));
}
Ok(())
}
/// Create cache-root components with owner-only permissions even when the
/// caller has selected a permissive umask.
pub fn create_cache_root(path: &Path) -> io::Result<()> {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
}
pub fn open_regular(path: &Path, write: bool, create: bool) -> io::Result<File> {
let file = OpenOptions::new()
.read(true)
.write(write)
.create(create)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file()
|| metadata.uid() != unsafe { libc::geteuid() }
|| metadata.mode() & 0o022 != 0
|| metadata.nlink() != 1
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"cache entry must be a private, regular, singly linked file",
));
}
Ok(file)
}
pub fn lock_exclusive(file: &File) -> io::Result<()> {
use std::os::fd::AsRawFd;
loop {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
}
/// Try to acquire an exclusive lock without waiting for an active loader.
pub fn try_lock_exclusive(file: &File) -> io::Result<bool> {
use std::os::fd::AsRawFd;
loop {
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
return Ok(true);
}
let error = io::Error::last_os_error();
if error.kind() == io::ErrorKind::WouldBlock {
return Ok(false);
}
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
}
/// An ACL that is not equivalent to ordinary mode bits would be lost by the
/// staging-file replacement path. Callers must skip replacement in that case.
#[cfg(target_os = "macos")]
pub fn has_extended_acl(file: &File) -> io::Result<bool> {
use std::os::fd::AsRawFd;
extern "C" {
fn acl_get_fd(fd: libc::c_int) -> *mut libc::c_void;
fn acl_free(object: *mut libc::c_void) -> libc::c_int;
}
let acl = unsafe { acl_get_fd(file.as_raw_fd()) };
if acl.is_null() {
let error = io::Error::last_os_error();
// Darwin reports an ordinary mode-only file as ENOENT rather than
// returning an empty ACL object.
return if error.raw_os_error() == Some(libc::ENOENT) {
Ok(false)
} else {
Err(error)
};
}
if unsafe { acl_free(acl) } != 0 {
return Err(io::Error::last_os_error());
}
Ok(true)
}
pub fn sync_directory(path: &Path) -> io::Result<()> {
File::open(path)?.sync_all()
}
/// Publish a same-filesystem staged file over its final pathname.
pub fn replace_file(source: &Path, destination: &Path) -> io::Result<()> {
fs::rename(source, destination)
}
/// Preserve installation permissions, group ownership, and security metadata
/// before replacing an image. Compression forks describe the carrier's bytes,
/// so the new raw image must regenerate those rather than inherit them.
pub fn copy_metadata(source: &File, destination: &File) -> io::Result<()> {
use std::os::fd::AsRawFd;
use xattr::FileExt;
let metadata = source.metadata()?;
if unsafe { libc::fchown(destination.as_raw_fd(), metadata.uid(), metadata.gid()) } != 0 {
return Err(io::Error::last_os_error());
}
destination.set_permissions(metadata.permissions())?;
for name in source.list_xattr()? {
if name == "com.apple.decmpfs" || name == "com.apple.ResourceFork" {
continue;
}
if let Some(value) = source.get_xattr(&name)? {
// Security labels can be inherited correctly while setting even
// that same value is forbidden by the host policy. Avoid needless
// changes, but fail the optimization if a differing label cannot
// be preserved.
if destination.get_xattr(&name)?.as_deref() != Some(value.as_slice()) {
destination.set_xattr(&name, &value)?;
}
}
}
Ok(())
}
/// Resolve an address within the mapped carrier, even after its directory
/// entry has been atomically replaced. No N-API path API is needed.
///
/// # Safety
/// `address` must point into a currently loaded native image.
pub unsafe fn carrier_path(address: *const std::ffi::c_void) -> Result<PathBuf> {
let mut info: libc::Dl_info = std::mem::zeroed();
if libc::dladdr(address, &mut info) == 0 || info.dli_fname.is_null() {
return Err(Error::new(
ErrorKind::Load,
"dladdr could not locate the native carrier",
));
}
let name = CStr::from_ptr(info.dli_fname).to_bytes();
let path = Path::new(std::ffi::OsStr::from_bytes(name));
fs::canonicalize(path).map_err(|e| {
Error::io(
ErrorKind::Load,
&format!("resolve carrier {}", path.display()),
e,
)
})
}
pub(crate) fn prepare_cache_image(_file: &File) -> io::Result<()> {
Ok(())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use std::path::Path;
use super::{cache_ancestor_is_secure, noexec_mount_in};
#[test]
fn selects_the_most_specific_mount_option() {
let mounts = "24 1 0:20 / / rw,relatime - ext4 /dev/root rw\n36 24 0:31 / /custom \
rw,noexec - tmpfs tmpfs rw\n";
assert!(noexec_mount_in(Path::new("/custom/cache"), mounts));
assert!(!noexec_mount_in(Path::new("/other/cache"), mounts));
}
#[test]
fn rejects_sticky_writable_directory_owned_by_another_user() {
assert!(cache_ancestor_is_secure(0o1777, 0, 1000));
assert!(cache_ancestor_is_secure(0o1777, 1000, 1000));
assert!(!cache_ancestor_is_secure(0o1777, 1001, 1000));
assert!(!cache_ancestor_is_secure(0o0755, 1001, 1000));
assert!(!cache_ancestor_is_secure(0o0555, 1001, 1000));
assert!(!cache_ancestor_is_secure(0o0777, 1000, 1000));
}
}
#[cfg(all(test, target_os = "macos"))]
mod macos_tests {
#[test]
fn recognizes_noexec_mount_flags() {
assert!(super::mount_is_noexec(libc::MNT_NOEXEC as u32));
assert!(!super::mount_is_noexec(0));
}
}