Skip to content

Commit 05b7e9f

Browse files
committed
sandlock-core: add confined statat/statx/utimensat/readlink helpers
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent a83cd38 commit 05b7e9f

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

  • crates/sandlock-core/src/sys

crates/sandlock-core/src/sys/fs.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,118 @@ pub(crate) fn openat2_in_root(
8383
}
8484
}
8585

86+
/// Empty C string for the `AT_EMPTY_PATH` family of calls (operate on the fd).
87+
const EMPTY: *const libc::c_char = b"\0".as_ptr() as *const libc::c_char;
88+
89+
/// Open a confined `O_PATH` handle to `path` under `root`.
90+
///
91+
/// `O_PATH` needs no read permission and does no I/O, so it is the right
92+
/// handle for metadata operations. `RESOLVE_IN_ROOT` confines every component
93+
/// of the walk to `root`. When `follow` is false the final component is opened
94+
/// with `O_NOFOLLOW`, yielding a handle to the symlink itself (lstat
95+
/// semantics); when true the final symlink is followed, but still confined.
96+
fn opath_in_root(root: &Path, path: &str, follow: bool) -> Result<RawFd, i32> {
97+
let mut flags = libc::O_PATH | libc::O_CLOEXEC;
98+
if !follow {
99+
flags |= libc::O_NOFOLLOW;
100+
}
101+
openat2_in_root(root, path, flags, 0)
102+
}
103+
104+
/// `stat`/`lstat` a path confined within `root`.
105+
///
106+
/// `follow` selects `stat` (follow the final symlink) vs `lstat` (stat the
107+
/// link itself) semantics for the final component; intermediate components are
108+
/// always confined to `root`.
109+
pub(crate) fn statat_in_root(root: &Path, path: &str, follow: bool) -> Result<libc::stat, i32> {
110+
let fd = opath_in_root(root, path, follow)?;
111+
let mut st: libc::stat = unsafe { std::mem::zeroed() };
112+
let rc = unsafe { libc::fstatat(fd, EMPTY, &mut st, libc::AT_EMPTY_PATH) };
113+
let err = last_errno(libc::EIO);
114+
unsafe { libc::close(fd) };
115+
if rc < 0 {
116+
Err(err)
117+
} else {
118+
Ok(st)
119+
}
120+
}
121+
122+
/// `statx` a path confined within `root`, writing the raw `struct statx` into
123+
/// `buf` (must be at least `sizeof(struct statx)`, 256 bytes). The child's
124+
/// `AT_SYMLINK_NOFOLLOW` in `flags` selects follow vs nofollow; other `flags`
125+
/// bits (sync hints) are preserved.
126+
pub(crate) fn statx_in_root(
127+
root: &Path,
128+
path: &str,
129+
flags: i32,
130+
mask: u32,
131+
buf: &mut [u8],
132+
) -> Result<(), i32> {
133+
let follow = (flags & libc::AT_SYMLINK_NOFOLLOW) == 0;
134+
let fd = opath_in_root(root, path, follow)?;
135+
// statx the handle itself via AT_EMPTY_PATH; follow/nofollow is already
136+
// baked into how the fd was opened, so drop AT_SYMLINK_NOFOLLOW here.
137+
let stx_flags = (flags & !libc::AT_SYMLINK_NOFOLLOW) | libc::AT_EMPTY_PATH;
138+
let rc = unsafe {
139+
libc::syscall(libc::SYS_statx, fd, EMPTY, stx_flags, mask, buf.as_mut_ptr())
140+
};
141+
let err = last_errno(libc::EIO);
142+
unsafe { libc::close(fd) };
143+
if rc < 0 {
144+
Err(err)
145+
} else {
146+
Ok(())
147+
}
148+
}
149+
150+
/// Set timestamps on a path confined within `root`.
151+
///
152+
/// Resolves a confined `O_PATH` handle, then stamps it through its
153+
/// `/proc/self/fd/N` magic link. The kernel cannot take an `O_PATH` fd
154+
/// directly for `utimensat` (EBADF), but the magic link jumps straight to the
155+
/// already-confined inode, so resolution cannot escape. `follow` selects
156+
/// whether the final symlink is followed or stamped in place (baked into how
157+
/// the handle is opened).
158+
pub(crate) fn utimensat_in_root(
159+
root: &Path,
160+
path: &str,
161+
times: *const libc::timespec,
162+
follow: bool,
163+
) -> Result<(), i32> {
164+
let fd = opath_in_root(root, path, follow)?;
165+
let proc = CString::new(format!("/proc/self/fd/{}", fd)).map_err(|_| {
166+
unsafe { libc::close(fd) };
167+
libc::EINVAL
168+
})?;
169+
let rc = unsafe { libc::utimensat(libc::AT_FDCWD, proc.as_ptr(), times, 0) };
170+
let err = last_errno(libc::EIO);
171+
unsafe { libc::close(fd) };
172+
if rc < 0 {
173+
Err(err)
174+
} else {
175+
Ok(())
176+
}
177+
}
178+
179+
/// Read a symlink's target confined within `root`. Returns the raw target
180+
/// bytes. `Err(EINVAL)` means the final component is not a symlink.
181+
pub(crate) fn readlink_in_root(root: &Path, path: &str) -> Result<Vec<u8>, i32> {
182+
// O_PATH|O_NOFOLLOW opens the link itself; readlinkat with an empty path
183+
// then reads its target. The parent walk is confined to root.
184+
let fd = opath_in_root(root, path, false)?;
185+
let mut buf = vec![0u8; 4096];
186+
let n = unsafe {
187+
libc::readlinkat(fd, EMPTY, buf.as_mut_ptr() as *mut libc::c_char, buf.len())
188+
};
189+
let err = last_errno(libc::EIO);
190+
unsafe { libc::close(fd) };
191+
if n < 0 {
192+
return Err(err);
193+
}
194+
buf.truncate(n as usize);
195+
Ok(buf)
196+
}
197+
86198
#[cfg(test)]
87199
mod tests {
88200
use super::*;
@@ -126,4 +238,97 @@ mod tests {
126238
Err(e) => panic!("unexpected error: {}", e),
127239
}
128240
}
241+
242+
// Skip a test body cleanly on kernels without openat2.
243+
macro_rules! skip_if_nosys {
244+
($e:expr) => {
245+
match $e {
246+
Err(libc::ENOSYS) => return,
247+
other => other,
248+
}
249+
};
250+
}
251+
252+
#[test]
253+
fn statat_lstat_does_not_follow_escaping_symlink() {
254+
let tmp = TempDir::new().unwrap();
255+
let root = tmp.path();
256+
symlink("/etc/group", root.join("evil")).unwrap();
257+
258+
// lstat: stat the link itself, confined; must report a symlink, never
259+
// the host /etc/group it points at.
260+
let st = skip_if_nosys!(statat_in_root(root, "evil", false)).unwrap();
261+
assert_eq!(st.st_mode & libc::S_IFMT, libc::S_IFLNK, "expected a symlink");
262+
263+
// stat (follow): the absolute target is clamped to <root>/etc/group,
264+
// which does not exist, so the host file is never reached.
265+
assert_eq!(statat_in_root(root, "evil", true), Err(libc::ENOENT));
266+
}
267+
268+
#[test]
269+
fn statat_confines_symlinked_parent() {
270+
let tmp = TempDir::new().unwrap();
271+
let root = tmp.path();
272+
symlink("/etc", root.join("dirlink")).unwrap();
273+
// dirlink/group would be /etc/group on the host; confined it is absent.
274+
assert_eq!(
275+
skip_if_nosys!(statat_in_root(root, "dirlink/group", true)),
276+
Err(libc::ENOENT)
277+
);
278+
}
279+
280+
#[test]
281+
fn readlink_confined_reads_in_tree_link_but_not_through_escaping_parent() {
282+
let tmp = TempDir::new().unwrap();
283+
let root = tmp.path();
284+
symlink("/etc/group", root.join("evil")).unwrap();
285+
symlink("/etc", root.join("dirlink")).unwrap();
286+
287+
// The link's own target string is the child's data: returning it is fine.
288+
let target = skip_if_nosys!(readlink_in_root(root, "evil")).unwrap();
289+
assert_eq!(target, b"/etc/group");
290+
291+
// But a link reached through an escaping parent is not visible.
292+
assert_eq!(readlink_in_root(root, "dirlink/group"), Err(libc::ENOENT));
293+
}
294+
295+
#[test]
296+
fn utimensat_confined_stamps_in_tree_and_refuses_escape() {
297+
let tmp = TempDir::new().unwrap();
298+
let root = tmp.path();
299+
std::fs::write(root.join("f"), "x").unwrap();
300+
symlink("/etc", root.join("dirlink")).unwrap();
301+
302+
let times = [
303+
libc::timespec { tv_sec: 1_000_000, tv_nsec: 0 },
304+
libc::timespec { tv_sec: 1_000_000, tv_nsec: 0 },
305+
];
306+
// In-tree file: stamp succeeds and takes effect.
307+
skip_if_nosys!(utimensat_in_root(root, "f", times.as_ptr(), true)).unwrap();
308+
let meta = std::fs::metadata(root.join("f")).unwrap();
309+
use std::os::unix::fs::MetadataExt;
310+
assert_eq!(meta.mtime(), 1_000_000);
311+
312+
// Escaping parent: refused, so the host /etc/group is never stamped.
313+
assert_eq!(
314+
utimensat_in_root(root, "dirlink/group", times.as_ptr(), true),
315+
Err(libc::ENOENT)
316+
);
317+
}
318+
319+
#[test]
320+
fn statx_confines_symlinked_parent() {
321+
let tmp = TempDir::new().unwrap();
322+
let root = tmp.path();
323+
std::fs::write(root.join("f"), "data").unwrap();
324+
symlink("/etc", root.join("dirlink")).unwrap();
325+
let mut buf = vec![0u8; 256];
326+
327+
// In-tree file resolves; escaping parent does not.
328+
skip_if_nosys!(statx_in_root(root, "f", 0, libc::STATX_BASIC_STATS, &mut buf)).unwrap();
329+
assert_eq!(
330+
statx_in_root(root, "dirlink/group", 0, libc::STATX_BASIC_STATS, &mut buf),
331+
Err(libc::ENOENT)
332+
);
333+
}
129334
}

0 commit comments

Comments
 (0)