Skip to main content

orbit_core/shm/
group.rs

1//! Explicit group access to dedicated IPC resources, never a fleet-wide policy.
2
3use std::fs::{File, OpenOptions};
4use std::io;
5use std::os::fd::AsRawFd;
6use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
7use std::path::{Path, PathBuf};
8
9use super::{LaneHold, ShmAccessPolicy, ShmRegion, ShmRegionLock, WatcherHold};
10
11/// Access to one deployment-owned IPC directory and its named SHM objects.
12///
13/// All group writers are trusted peers, not isolated principals. The owner
14/// prepares lock inodes; other users only attach. The parent directory must
15/// be provisioned by the operator and protected from untrusted replacement.
16/// No credentials, umask, existing permissions or unrelated resources change.
17#[derive(Clone, Debug, Eq, PartialEq, Hash)]
18pub struct GroupAccess {
19    owner: u32,
20    gid: u32,
21    directory: PathBuf,
22}
23
24impl GroupAccess {
25    pub fn new(
26        owner: u32,
27        gid: u32,
28        directory: impl AsRef<Path>,
29    ) -> io::Result<Self> {
30        let directory = directory.as_ref();
31        if owner == !0 || gid == !0 || !directory.is_absolute() {
32            return Err(io::Error::new(
33                io::ErrorKind::InvalidInput,
34                "explicit owner, group and absolute IPC directory required",
35            ));
36        }
37        let leaf = directory.file_name().ok_or_else(|| {
38            io::Error::new(
39                io::ErrorKind::InvalidInput,
40                "IPC directory must not be a filesystem root",
41            )
42        })?;
43        let parent = directory.parent().unwrap().canonicalize()?;
44        // Resolve only the trusted parent, never a preexisting final symlink.
45        Ok(Self { owner, gid, directory: parent.join(leaf) })
46    }
47
48    pub fn owner_uid(&self) -> u32 {
49        self.owner
50    }
51    pub fn gid(&self) -> u32 {
52        self.gid
53    }
54    pub fn directory(&self) -> &Path {
55        &self.directory
56    }
57    pub fn is_owner(&self) -> bool {
58        (unsafe { libc::geteuid() }) == self.owner
59    }
60
61    /// Scope the SHM name to the same owner/group/directory as its kernel locks.
62    /// This prevents two IPC directories from supplying different death locks
63    /// for the same mapped inbox. The name also fits Darwin's 31-byte limit.
64    pub fn segment_name(
65        &self,
66        fleet: &str,
67        kind: u8,
68    ) -> String {
69        let mut hash = blake3::Hasher::new();
70        hash.update(b"orbit/group-resource/v1\0");
71        hash.update(&self.owner.to_le_bytes());
72        hash.update(&self.gid.to_le_bytes());
73        hash.update(&[kind]);
74        let directory = self.directory.as_os_str().as_encoded_bytes();
75        hash.update(&(directory.len() as u64).to_le_bytes());
76        hash.update(directory);
77        hash.update(fleet.as_bytes());
78        format!("/orbit-{}", &hash.finalize().to_hex()[..24])
79    }
80
81    /// Prepare once as owner; attaching peers never create directories.
82    pub fn prepare(&self) -> io::Result<()> {
83        self.open_directory(&self.directory, 0o750)?;
84        self.open_directory(&self.directory.join("bells"), 0o2770)?;
85        Ok(())
86    }
87
88    fn open_directory(
89        &self,
90        path: &Path,
91        mode: u32,
92    ) -> io::Result<File> {
93        let created = if self.is_owner() {
94            match std::fs::DirBuilder::new().mode(0o700).create(path) {
95                Ok(()) => true,
96                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => false,
97                Err(e) => return Err(e),
98            }
99        } else {
100            false
101        };
102        let file = OpenOptions::new()
103            .read(true)
104            .custom_flags(
105                libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
106            )
107            .open(path)?;
108        if created {
109            self.configure(&file, mode)?;
110        }
111        let m = file.metadata()?;
112        if !m.is_dir()
113            || m.uid() != self.owner
114            || m.gid() != self.gid
115            || m.mode() & 0o7777 != mode
116        {
117            return Err(denied("IPC directory owner/group/mode mismatch"));
118        }
119        Ok(file)
120    }
121
122    fn configure(
123        &self,
124        file: &File,
125        mode: u32,
126    ) -> io::Result<()> {
127        if unsafe { libc::fchown(file.as_raw_fd(), !0, self.gid) } != 0 {
128            return Err(io::Error::last_os_error());
129        }
130        if unsafe { libc::fchmod(file.as_raw_fd(), mode as libc::mode_t) } != 0
131        {
132            return Err(io::Error::last_os_error());
133        }
134        Ok(())
135    }
136
137    fn lock_file(
138        &self,
139        name: &str,
140    ) -> io::Result<std::os::fd::OwnedFd> {
141        use std::ffi::CString;
142        use std::os::fd::{FromRawFd, OwnedFd};
143        if name.is_empty() || name.contains('/') || name == "." || name == ".."
144        {
145            return Err(io::Error::new(
146                io::ErrorKind::InvalidInput,
147                "invalid IPC lock name",
148            ));
149        }
150        let directory = self.open_directory(&self.directory, 0o750)?;
151        let name = CString::new(name)
152            .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
153        let mut created = false;
154        let flags = libc::O_RDWR | libc::O_CLOEXEC | libc::O_NOFOLLOW;
155        let raw = if self.is_owner() {
156            let fd = unsafe {
157                libc::openat(
158                    directory.as_raw_fd(),
159                    name.as_ptr(),
160                    flags | libc::O_CREAT | libc::O_EXCL,
161                    0o600 as libc::c_uint,
162                )
163            };
164            if fd >= 0 {
165                created = true;
166                fd
167            } else if io::Error::last_os_error().raw_os_error()
168                == Some(libc::EEXIST)
169            {
170                unsafe {
171                    libc::openat(directory.as_raw_fd(), name.as_ptr(), flags)
172                }
173            } else {
174                return Err(io::Error::last_os_error());
175            }
176        } else {
177            unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) }
178        };
179        if raw < 0 {
180            return Err(io::Error::last_os_error());
181        }
182        let file = File::from(unsafe { OwnedFd::from_raw_fd(raw) });
183        if created {
184            self.configure(&file, 0o660)?;
185        }
186        let m = file.metadata()?;
187        if !m.is_file()
188            || m.uid() != self.owner
189            || m.gid() != self.gid
190            || m.mode() & 0o7777 != 0o660
191            || m.nlink() != 1
192        {
193            return Err(denied("IPC lock owner/group/mode/type mismatch"));
194        }
195        Ok(file.into())
196    }
197
198    /// Prepare bounded lane/watcher lock files before publishing the inbox.
199    pub fn prepare_locks(
200        &self,
201        segment: &str,
202        lanes: usize,
203    ) -> io::Result<()> {
204        self.prepare()?;
205        for lane in 0..lanes {
206            self.lock_file(&format!(
207                "{}.lane{lane}.lock",
208                segment.trim_start_matches('/')
209            ))?;
210            self.lock_file(&format!(
211                "{}.watch{lane}.lock",
212                segment.trim_start_matches('/')
213            ))?;
214        }
215        Ok(())
216    }
217
218    pub fn open_region(
219        &self,
220        name: &str,
221        size: usize,
222    ) -> io::Result<(ShmRegion, ShmRegionLock)> {
223        self.prepare()?;
224        let fd =
225            self.lock_file(&format!("{}.lock", name.trim_start_matches('/')))?;
226        let lock = super::lock_fd_exclusive(fd)?;
227        let policy = ShmAccessPolicy::GroupReadWrite { gid: self.gid };
228        let region = if self.is_owner() {
229            ShmRegion::open_or_create_with_policy(name, size, policy)?
230        } else {
231            ShmRegion::open_existing_read_write(name, size, self.owner, policy)?
232        };
233        // In particular Darwin cannot repair creation permissions afterwards.
234        // Refuse a creation mask that would silently omit group write access.
235        let result = self.verify_shared_mode(name);
236        if let Err(error) = result {
237            if region.created() {
238                // Keep group lock inodes, including the initialization lock
239                // held by this call. Never visit the private lock directory.
240                let _ = unsafe { libc::shm_unlink(region.name.as_ptr()) };
241            }
242            return Err(error);
243        }
244        Ok((region, lock))
245    }
246
247    /// Remove only a group-shared name. Persistent lock inodes are not removed
248    /// on restart and private per-UID companion directories are never visited.
249    /// All participants must already be stopped, as with ordinary SHM unlink.
250    pub fn unlink_region(
251        &self,
252        region: &ShmRegion,
253    ) -> io::Result<()> {
254        if !self.is_owner() {
255            return Err(denied("only the group resource owner may unlink"));
256        }
257        self.verify_shared_mode(
258            region
259                .name
260                .to_str()
261                .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?,
262        )?;
263        if unsafe { libc::shm_unlink(region.name.as_ptr()) } != 0 {
264            return Err(io::Error::last_os_error());
265        }
266        Ok(())
267    }
268
269    fn verify_shared_mode(
270        &self,
271        name: &str,
272    ) -> io::Result<()> {
273        use std::os::fd::{FromRawFd, OwnedFd};
274        let name = std::ffi::CString::new(name)
275            .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
276        let raw = unsafe { libc::shm_open(name.as_ptr(), libc::O_RDONLY, 0) };
277        if raw < 0 {
278            return Err(io::Error::last_os_error());
279        }
280        let file = File::from(unsafe { OwnedFd::from_raw_fd(raw) });
281        let m = file.metadata()?;
282        if m.uid() != self.owner
283            || m.gid() != self.gid
284            || m.mode() & 0o7777 != 0o660
285        {
286            return Err(denied(
287                "shared SHM requires exact owner/group and mode 0660; check the owner's creation mask",
288            ));
289        }
290        Ok(())
291    }
292
293    pub fn try_hold_lane(
294        &self,
295        segment: &str,
296        lane: usize,
297    ) -> io::Result<Option<LaneHold>> {
298        Ok(self
299            .try_hold(&format!(
300                "{}.lane{lane}.lock",
301                segment.trim_start_matches('/')
302            ))?
303            .map(|lock_fd| LaneHold { lock_fd }))
304    }
305
306    pub fn try_hold_watcher(
307        &self,
308        segment: &str,
309        slot: usize,
310    ) -> io::Result<Option<WatcherHold>> {
311        Ok(self
312            .try_hold(&format!(
313                "{}.watch{slot}.lock",
314                segment.trim_start_matches('/')
315            ))?
316            .map(|lock_fd| WatcherHold { lock_fd }))
317    }
318
319    fn try_hold(
320        &self,
321        name: &str,
322    ) -> io::Result<Option<std::os::fd::OwnedFd>> {
323        let fd = self.lock_file(name)?;
324        if unsafe { libc::flock(fd.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }
325            == 0
326        {
327            return Ok(Some(fd));
328        }
329        let e = io::Error::last_os_error();
330        if e.kind() == io::ErrorKind::WouldBlock { Ok(None) } else { Err(e) }
331    }
332
333    /// The group-writable bell directory is separate from immutable lock names.
334    /// A caller must own the corresponding lane before binding/removing a bell.
335    pub fn bell_path(
336        &self,
337        segment: &str,
338        suffix: &str,
339    ) -> PathBuf {
340        self.directory
341            .join("bells")
342            .join(format!("{}.{suffix}", segment.trim_start_matches('/')))
343    }
344
345    pub fn bind_bell(
346        &self,
347        path: &Path,
348    ) -> io::Result<std::os::unix::net::UnixDatagram> {
349        use std::os::unix::fs::FileTypeExt;
350        let expected = self.directory.join("bells");
351        if path.parent() != Some(expected.as_path()) {
352            return Err(denied("bell outside IPC directory"));
353        }
354        self.open_directory(&expected, 0o2770)?;
355        match std::fs::symlink_metadata(path) {
356            Ok(m) if m.file_type().is_socket() && m.gid() == self.gid => {
357                std::fs::remove_file(path)?
358            }
359            Ok(_) => {
360                return Err(denied("refusing to replace non-socket IPC bell"));
361            }
362            Err(e) if e.kind() == io::ErrorKind::NotFound => {}
363            Err(e) => return Err(e),
364        }
365        let socket = std::os::unix::net::UnixDatagram::bind(path)?;
366        // Setgid inheritance supplies the group. Never chmod/chown a pathname
367        // in the writable bell directory: a peer could swap it for a symlink.
368        let m = std::fs::symlink_metadata(path)?;
369        if !m.file_type().is_socket()
370            || m.gid() != self.gid
371            || m.mode() & 0o020 == 0
372        {
373            return Err(denied(
374                "IPC socket group/write permission mismatch; check creation mask",
375            ));
376        }
377        socket.set_nonblocking(true)?;
378        Ok(socket)
379    }
380}
381
382fn denied(message: &'static str) -> io::Error {
383    io::Error::new(io::ErrorKind::PermissionDenied, message)
384}