@@ -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) ]
87199mod 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