Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ jsonc-parser = { version = "0.32.0", features = ["serde"] }
libc = "0.2.185"
libtest-mimic = "0.8.2"
memmap2 = "0.9.11"
memfd = "0.6.5"
monostate = "1.0.2"
napi = "3"
napi-build = "2"
Expand Down Expand Up @@ -116,7 +117,6 @@ ref-cast = "1.0.24"
regex = "1.11.3"
rusqlite = "0.39.0"
rustc-hash = "2.1.1"
rustix = "1.1"
# SeccompAction::UserNotif (SECCOMP_RET_USER_NOTIF) was added after the latest published release (v0.5.0)
seccompiler = { git = "https://github.com/rust-vmm/seccompiler", rev = "08587106340b8e3cb361c7561411510039436857" }
serde = "1.0.219"
Expand Down
5 changes: 3 additions & 2 deletions crates/fspy_shm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ rust-version.workspace = true

[target.'cfg(target_os = "linux")'.dependencies]
memmap2 = { workspace = true }
memfd = { workspace = true }
passfd = { workspace = true, features = ["async"] }
rustix = { workspace = true, features = ["fs", "net", "process"] }
nix = { workspace = true, features = ["fs", "socket", "user"] }
tokio = { workspace = true, features = ["macros", "net", "rt", "time"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
Expand All @@ -19,7 +20,7 @@ uuid = { workspace = true, features = ["v4"] }
[target.'cfg(target_os = "macos")'.dependencies]
base64 = { workspace = true }
memmap2 = { workspace = true }
rustix = { workspace = true, features = ["fs", "shm"] }
nix = { workspace = true, features = ["fs", "mman"] }
uuid = { workspace = true, features = ["v4"] }

[target.'cfg(target_os = "windows")'.dependencies]
Expand Down
54 changes: 28 additions & 26 deletions crates/fspy_shm/src/linux/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ use std::{
sync::Arc,
};

use passfd::{FdPassingExt as SyncFdPassingExt, tokio::FdPassingExt as AsyncFdPassingExt};
use rustix::{
io::Errno,
net::{
AddressFamily, SocketAddrUnix, SocketFlags, SocketType, connect, socket_with,
sockopt::socket_peercred,
use nix::{
sys::socket::{
AddressFamily, SockFlag, SockType, UnixAddr, connect, getsockopt, socket,
sockopt::PeerCredentials,
},
process::{Uid, geteuid},
unistd::{Uid, geteuid},
};
use passfd::{FdPassingExt as SyncFdPassingExt, tokio::FdPassingExt as AsyncFdPassingExt};
use tokio::{net::UnixListener, task::JoinSet};
use tokio_util::sync::{CancellationToken, DropGuard};
use tracing::{debug, warn};
Expand Down Expand Up @@ -64,14 +63,14 @@ async fn run_broker(
// https://man7.org/linux/man-pages/man7/unix.7.html
// D-Bus prefers the same mechanism because it requires no peer cooperation:
// https://gitlab.freedesktop.org/dbus/dbus/-/blob/958bf9db2100553bcd2fe2a854e1ebb42e886054/dbus/dbus-sysdeps-unix.c#L2296-2303
let credentials = match socket_peercred(&client) {
let credentials = match getsockopt(&client, PeerCredentials) {
Ok(credentials) => credentials,
Err(error) => {
debug!("shared-memory broker failed to read peer credentials: {error}");
continue;
}
};
if credentials.uid != owner_uid {
if credentials.uid() != owner_uid.as_raw() {
debug!("shared-memory broker rejected a client owned by another user");
continue;
}
Expand All @@ -93,15 +92,14 @@ async fn run_broker(
}
}

pub(super) fn request_memfd(id: &str) -> rustix::io::Result<OwnedFd> {
pub(super) fn request_memfd(id: &str) -> io::Result<OwnedFd> {
// Prevent the broker connection from leaking into later execs.
let socket = socket_with(AddressFamily::UNIX, SocketType::STREAM, SocketFlags::CLOEXEC, None)?;
let address = SocketAddrUnix::new_abstract_name(id.as_bytes())?;
connect(&socket, &address)?;
let socket = socket(AddressFamily::Unix, SockType::Stream, SockFlag::SOCK_CLOEXEC, None)?;
let address = UnixAddr::new_abstract(id.as_bytes())?;
connect(socket.as_raw_fd(), &address)?;
// `SCM_RIGHTS` does not preserve descriptor flags; `passfd` sets
// `FD_CLOEXEC` on the received descriptor before returning it.
let descriptor = SyncFdPassingExt::recv_fd(&socket.as_raw_fd())
.map_err(|error| Errno::from_io_error(&error).unwrap_or(Errno::IO))?;
let descriptor = SyncFdPassingExt::recv_fd(&socket.as_raw_fd())?;
// SAFETY: passfd returns a newly received descriptor owned by the caller.
Ok(unsafe { OwnedFd::from_raw_fd(descriptor) })
}
Expand All @@ -110,10 +108,8 @@ pub(super) fn request_memfd(id: &str) -> rustix::io::Result<OwnedFd> {
mod tests {
use std::{io, process::Command};

use rustix::{
fs::{SealFlags, fcntl_get_seals},
io::{FdFlags, fcntl_getfd},
};
use memfd::MemfdOptions;
use nix::fcntl::{FcntlArg, FdFlag, SealFlag, fcntl};
use subprocess_test::command_for_fn;

use super::*;
Expand Down Expand Up @@ -152,9 +148,12 @@ mod tests {

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn broker_stops_when_guard_drops() {
let memfd = rustix::fs::memfd_create("shared-memory-test", rustix::fs::MemfdFlags::CLOEXEC)
.map_err(io::Error::from)
.unwrap();
let memfd: OwnedFd = MemfdOptions::new()
.close_on_exec(true)
.create("shared-memory-test")
.unwrap()
.into_file()
.into();
let (_id, service, guard) = new(memfd).unwrap();
let broker = tokio::spawn(service);
tokio::task::yield_now().await;
Expand All @@ -172,10 +171,13 @@ mod tests {
let owner = crate::create(4096).unwrap();
let id = owner.id().to_owned();
let descriptor = request_memfd_blocking(id).await.unwrap();
assert!(fcntl_getfd(&descriptor).unwrap().contains(FdFlags::CLOEXEC));
assert!(
FdFlag::from_bits_retain(fcntl(&descriptor, FcntlArg::F_GETFD).unwrap())
.contains(FdFlag::FD_CLOEXEC)
);
assert_eq!(
fcntl_get_seals(&descriptor).unwrap(),
SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL
SealFlag::from_bits_retain(fcntl(&descriptor, FcntlArg::F_GET_SEALS).unwrap()),
SealFlag::F_SEAL_GROW | SealFlag::F_SEAL_SHRINK | SealFlag::F_SEAL_SEAL
);
}

Expand Down Expand Up @@ -223,7 +225,7 @@ mod tests {
assert!(open_blocking(id).await.is_ok());
}

async fn request_memfd_blocking(id: String) -> rustix::io::Result<OwnedFd> {
async fn request_memfd_blocking(id: String) -> io::Result<OwnedFd> {
tokio::task::spawn_blocking(move || request_memfd(&id)).await.unwrap()
}

Expand Down
29 changes: 22 additions & 7 deletions crates/fspy_shm/src/linux/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

mod broker;

use std::{io, slice};
use std::{io, os::fd::OwnedFd, slice};

use memfd::{FileSeal, MemfdOptions};
use memmap2::{MmapOptions, MmapRaw};
use rustix::fs::{MemfdFlags, SealFlags, fcntl_add_seals, fstat, ftruncate, memfd_create};
use nix::sys::stat::fstat;
use tokio_util::sync::DropGuard;

/// An owned Linux shared-memory mapping.
Expand Down Expand Up @@ -33,13 +34,19 @@ pub fn create(size: usize) -> io::Result<Shm> {
let size_u64 = valid_size(size)?;
// Prevent the descriptor from leaking across exec while permitting the
// size and seal set to be locked after initialization.
let memfd =
memfd_create("vite-task-shared-memory", MemfdFlags::CLOEXEC | MemfdFlags::ALLOW_SEALING)?;
ftruncate(&memfd, size_u64)?;
let memfd = MemfdOptions::new()
.allow_sealing(true)
.close_on_exec(true)
.create("vite-task-shared-memory")
.map_err(memfd_error)?;
memfd.as_file().set_len(size_u64)?;
// Keep the initialized size fixed and prevent removal of these seals;
// writes through the shared mapping remain allowed.
fcntl_add_seals(&memfd, SealFlags::GROW | SealFlags::SHRINK | SealFlags::SEAL)?;
let mapping = MmapOptions::new().len(size).map_raw(&memfd)?;
memfd
.add_seals(&[FileSeal::SealGrow, FileSeal::SealShrink, FileSeal::SealSeal])
.map_err(memfd_error)?;
let mapping = MmapOptions::new().len(size).map_raw(memfd.as_file())?;
let memfd: OwnedFd = memfd.into_file().into();
let (id, service, guard) = broker::new(memfd)?;
runtime.spawn(service);

Expand Down Expand Up @@ -79,6 +86,14 @@ fn valid_size(size: usize) -> io::Result<u64> {
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64"))
}

fn memfd_error(error: memfd::Error) -> io::Error {
match error {
memfd::Error::Create(error)
| memfd::Error::AddSeals(error)
| memfd::Error::GetSeals(error) => error,
}
}

#[expect(clippy::len_without_is_empty, reason = "shared-memory mappings are always non-empty")]
impl Shm {
/// Returns this mapping's opaque broker identifier.
Expand Down
52 changes: 34 additions & 18 deletions crates/fspy_shm/src/macos/mod.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
#![doc = include_str!("README.md")]

use std::{io, slice};
use std::{io, os::fd::AsFd, slice};

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use memmap2::{MmapOptions, MmapRaw};
use rustix::{
fs::{Mode, fstat, ftruncate},
io::Errno,
shm::{self, OFlags},
use nix::{
errno::Errno,
fcntl::{FcntlArg, FdFlag, OFlag, fcntl},
sys::{
mman::{shm_open, shm_unlink},
stat::{Mode, fstat},
},
unistd::ftruncate,
};
use uuid::Uuid;

Expand All @@ -33,31 +37,34 @@ pub fn create(size: usize) -> io::Result<Shm> {
"shared-memory size must be nonzero",
));
}
let size_u64 = u64::try_from(size).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds u64")
let size_i64 = i64::try_from(size).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "shared-memory size exceeds supported range")
})?;

loop {
let id = new_id();
// `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor.
let fd = match shm::open(
let fd = match shm_open(
id.as_str(),
OFlags::CREATE | OFlags::EXCL | OFlags::RDWR,
Mode::RUSR | Mode::WUSR,
OFlag::O_CREAT | OFlag::O_EXCL | OFlag::O_RDWR,
Mode::S_IRUSR | Mode::S_IWUSR,
) {
Ok(fd) => fd,
Err(Errno::EXIST) => continue,
Err(Errno::EEXIST) => continue,
Err(error) => return Err(error.into()),
};

if let Err(error) = ftruncate(&fd, size_u64) {
let _ = shm::unlink(id.as_str());
if let Err(error) = ensure_cloexec(&fd) {
let _ = shm_unlink(id.as_str());
return Err(error.into());
}
if let Err(error) = ftruncate(&fd, size_i64) {
let _ = shm_unlink(id.as_str());
return Err(error.into());
}
let mapping = match MmapOptions::new().len(size).map_raw(&fd) {
Ok(mapping) => mapping,
Err(error) => {
let _ = shm::unlink(id.as_str());
let _ = shm_unlink(id.as_str());
return Err(error);
}
};
Expand All @@ -72,8 +79,8 @@ pub fn create(size: usize) -> io::Result<Shm> {
///
/// Returns an error if the mapping is unavailable.
pub fn open(id: &str) -> io::Result<Shm> {
// `rustix::shm::open` sets `FD_CLOEXEC` on the returned descriptor.
let fd = shm::open(id, OFlags::RDWR, Mode::empty()).map_err(io::Error::from)?;
let fd = shm_open(id, OFlag::O_RDWR, Mode::empty())?;
ensure_cloexec(&fd)?;
// If another process shrinks the object before `mmap`, `mmap` returns an
// error. If it resizes the object after `mmap`, `open` does not access the
// mapped pages. A concurrent resize cannot make `open` access invalid memory.
Expand All @@ -92,10 +99,19 @@ fn new_id() -> String {
format!("{NAME_PREFIX}{}", URL_SAFE_NO_PAD.encode(Uuid::new_v4().as_bytes()))
}

// macOS rejects O_CLOEXEC for shm_open, so preserve the descriptor guarantee via fcntl.
fn ensure_cloexec<Fd: AsFd>(fd: &Fd) -> nix::Result<()> {
let flags = FdFlag::from_bits_retain(fcntl(fd, FcntlArg::F_GETFD)?);
if !flags.contains(FdFlag::FD_CLOEXEC) {
fcntl(fd, FcntlArg::F_SETFD(flags | FdFlag::FD_CLOEXEC))?;
}
Ok(())
}

impl Drop for Shm {
fn drop(&mut self) {
if self.owner {
let _ = shm::unlink(self.id.as_str());
let _ = shm_unlink(self.id.as_str());
}
}
}
Expand Down
Loading