Skip to main content

proot/fs/
canon.rs

1//! Component-wise canonicalization of guest paths (port of PRoot's `canon.c`),
2//! walked on descriptors.
3//!
4//! Every component of a guest path is looked up on the host side of its
5//! binding, relative to an `O_PATH` descriptor of the directory walked so
6//! far, never through a textual host path: a symlink found there is read and
7//! re-interpreted *in the guest namespace*, so a link to `/usr/lib` inside
8//! the rootfs lands in the rootfs's `/usr/lib`, never the host's.  The walk
9//! starts from the descriptors [`Fs`] holds open on the root and on every
10//! binding, and ends on a [`Resolved`]: the pinned parent directory and the
11//! final name, which the handlers act on with the `*at` syscalls and
12//! `O_NOFOLLOW`.  A directory the guest renames away and replaces by a
13//! symlink while a call is in flight is then seen as the symlink it is, and
14//! resolved inside the guest on a retry, instead of followed on the host by
15//! the kernel's own walk of a stale string.  Directories already resolved are
16//! kept in [`Fs`]'s cache to skip the `openat` next time.
17
18use super::Fs;
19use crate::sys::{Errno, PATH_MAX, Result, errno};
20use std::collections::VecDeque;
21use std::ffi::CString;
22use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
23use std::sync::Arc;
24
25/// Symlink hops allowed before giving up with `ELOOP` (Linux's `MAXSYMLINKS`).
26const MAX_SYMLINKS: u32 = 40;
27
28/// Walks started over, after a component changed under the walk, before
29/// giving up with `ELOOP`.
30const MAX_RESTARTS: u32 = 3;
31
32/// Sentinel errno: the object changed between two steps of a lookup or
33/// between the lookup and the call; start over from the guest path.  Never
34/// reaches a tracee: [`resolve`] and the handlers turn it into `ELOOP` once
35/// the restarts are used up.
36pub const RETRY: Errno = -2;
37
38/// Where the object a guest path names is, for the `*at` syscalls.
39#[derive(Debug)]
40pub enum At {
41    /// `name` inside the pinned directory `parent`.  Acted on with
42    /// `O_NOFOLLOW`/`AT_SYMLINK_NOFOLLOW`: the walk already followed the
43    /// name when asked to, so a symlink there now was swapped in since.
44    Child { parent: Arc<OwnedFd>, name: Vec<u8> },
45    /// The pinned object itself: the root or a binding, reached through
46    /// `/proc/self/fd/<n>`, which the kernel resolves to the open inode.
47    Object(Arc<OwnedFd>),
48    /// Only the textual host path: a `/proc` magic link the kernel must
49    /// re-open itself, or a mount point whose host side does not exist yet.
50    Path,
51}
52
53/// A guest path resolved inside the guest namespace.
54#[derive(Debug)]
55pub struct Resolved {
56    /// The canonical absolute guest path.
57    pub guest: Vec<u8>,
58    /// Its textual host path, for logging, identity checks and the `/proc`
59    /// fakes; not what the syscall acts on when `at` is pinned.
60    pub host: Vec<u8>,
61    /// What the syscall acts on.
62    pub at: At,
63}
64
65impl Resolved {
66    /// The `(dirfd, path, nofollow)` triple for a `*at` syscall.
67    ///
68    /// # Arguments
69    /// * `nofollow` - Whether the caller asked not to follow a final symlink.
70    ///
71    /// # Returns
72    /// The descriptor and path to pass, and whether to pass the no-follow
73    /// flag: always for a pinned name, never through a `/proc/self/fd` link
74    /// (the object behind it is never a symlink), as asked otherwise.
75    pub fn at(&self, nofollow: bool) -> Result<(RawFd, CString, bool)> {
76        match &self.at {
77            At::Child { parent, name } => Ok((parent.as_raw_fd(), cstr(name)?, true)),
78            At::Object(fd) => Ok((libc::AT_FDCWD, fd_path(fd), false)),
79            At::Path => Ok((libc::AT_FDCWD, cstr(&self.host)?, nofollow)),
80        }
81    }
82
83    /// Whether the syscall acts through a pinned parent or object.
84    pub fn pinned(&self) -> bool {
85        !matches!(self.at, At::Path)
86    }
87
88    /// Whether the path names a mount point of the view itself.
89    pub fn is_mount_point(&self) -> bool {
90        matches!(self.at, At::Object(_))
91    }
92
93    /// Whether the path is a final `/proc/<pid>/fd/N` or `map_files/X` link left to the kernel.
94    pub fn is_proc_fd_link(&self) -> bool {
95        matches!(self.at, At::Path) && is_proc_fd_link(&self.guest)
96    }
97
98    /// `st_mode` of the object, without following a final symlink.
99    pub fn mode_nofollow(&self) -> Result<u32> {
100        let mut st: libc::stat = unsafe { std::mem::zeroed() };
101        let (dfd, c, nofollow) = self.at(true)?;
102        let flags = if nofollow { libc::AT_SYMLINK_NOFOLLOW } else { 0 };
103        if unsafe { libc::fstatat(dfd, c.as_ptr(), &mut st, flags) } < 0 {
104            return Err(errno());
105        }
106        Ok(st.st_mode)
107    }
108
109    /// An `O_PATH` descriptor of the object itself, for the syscalls without
110    /// a `dirfd` form, which then act on `/proc/self/fd/<n>`.
111    ///
112    /// # Arguments
113    /// * `deref` - Whether the caller asked to follow a final symlink.  The
114    ///   walk did so already, so a symlink found here was swapped in since
115    ///   and the lookup is retried; without `deref` a symlink is returned as
116    ///   the object, with its mode, for the caller to refuse or to act on
117    ///   through its parent.
118    ///
119    /// # Returns
120    /// The descriptor and the object's `st_mode`.
121    pub fn object(&self, deref: bool) -> Result<(Arc<OwnedFd>, u32)> {
122        let fd = match &self.at {
123            At::Object(fd) => fd.clone(),
124            At::Child { parent, name } => Arc::new(open_at(parent, name, libc::O_NOFOLLOW)?),
125            At::Path => {
126                let c = cstr(&self.host)?;
127                let flags = libc::O_PATH | libc::O_CLOEXEC | if deref { 0 } else { libc::O_NOFOLLOW };
128                let fd = unsafe { libc::open(c.as_ptr(), flags) };
129                if fd < 0 {
130                    return Err(errno());
131                }
132                Arc::new(unsafe { OwnedFd::from_raw_fd(fd) })
133            }
134        };
135        let mode = fstat_mode(&fd)?;
136        if deref && self.pinned() && mode & libc::S_IFMT == libc::S_IFLNK {
137            return Err(RETRY);
138        }
139        Ok((fd, mode))
140    }
141
142    /// The path of the object with only its parents pinned,
143    /// `/proc/self/fd/<parent>/<name>`, for a syscall that must see the
144    /// name as it is now (the `l*xattr` family, a socket address).
145    pub fn parent_path(&self) -> Result<CString> {
146        match &self.at {
147            At::Child { parent, name } => {
148                let mut p = format!("/proc/self/fd/{}/", parent.as_raw_fd()).into_bytes();
149                p.extend_from_slice(name);
150                cstr(&p)
151            }
152            At::Object(fd) => Ok(fd_path(fd)),
153            At::Path => cstr(&self.host),
154        }
155    }
156}
157
158/// `/proc/self/fd/<n>` of a descriptor, as a C string.
159pub fn fd_path(fd: &OwnedFd) -> CString {
160    CString::new(format!("/proc/self/fd/{}", fd.as_raw_fd())).unwrap()
161}
162
163/// What becomes of the components after a missing intermediate one.
164#[derive(Clone, Copy, PartialEq, Eq)]
165enum Missing {
166    /// They are appended lexically: the path a binding table or a mount
167    /// point needs names where the object would be.
168    Lexical,
169    /// The lookup fails with `ENOENT`, as the kernel's would.
170    Errno,
171}
172
173/// Canonicalizes an absolute guest path inside the guest namespace.
174///
175/// Unlike [`resolve`], a path whose intermediate component is missing stays
176/// lexical from there on, as the binding table needs it.
177///
178/// # Arguments
179/// * `fs` - Guest filesystem view.
180/// * `pid` - Tracee thread id the path belongs to (for `/proc/self`).
181/// * `guest` - Absolute guest path, may contain `.`/`..`/double slashes.
182/// * `deref_final` - Follow a symlink in the last component.
183///
184/// # Returns
185/// The canonical absolute guest path (no `.`/`..`/symlinks in the walked part).
186///
187/// # Errors
188/// As [`resolve`], except for a missing intermediate component.
189pub fn canonicalize(fs: &Fs, pid: u32, guest: &[u8], deref_final: bool) -> Result<Vec<u8>> {
190    resolve_with(fs, pid, guest, deref_final, Missing::Lexical).map(|r| r.guest)
191}
192
193/// Resolves an absolute guest path inside the guest namespace to the pinned
194/// directory and name to act on.
195///
196/// Walks `guest` one component at a time.  Each intermediate component is
197/// opened `O_PATH` relative to the directory walked so far; a symlink is
198/// read and its target re-entered into the walk as a guest path (absolute
199/// targets restart from guest `/`).  The final component is dereferenced
200/// only when `deref_final` is true.  A final component that does not exist
201/// is returned as a name in its parent, so the kernel produces the real
202/// error, or creates it, through the pinned parent; a missing or non-directory
203/// intermediate component fails here with the errno the kernel would give.
204///
205/// `/proc` magic links are special: `/proc/self` and `/proc/thread-self`
206/// become the tracee's own entries (not the supervisor's), the targets of
207/// `/proc/<pid>/{exe,cwd,root,fd/N,map_files/*}` are host paths, so they are
208/// detranslated instead of being re-walked as guest paths, and
209/// `/proc/<pid>/ns/*` name namespaces, which only the kernel resolves.
210///
211/// # Arguments
212/// * `fs` - Guest filesystem view.
213/// * `pid` - Tracee thread id the path belongs to (for `/proc/self`).
214/// * `guest` - Absolute guest path, may contain `.`/`..`/double slashes.
215/// * `deref_final` - Follow a symlink in the last component.
216///
217/// # Errors
218/// `ELOOP` after too many symlinks, or when a component kept changing under
219/// the walk; `ENAMETOOLONG` for an overlong result; `ENOTDIR` when an
220/// intermediate component is not a directory; `ENOENT` when one is missing;
221/// `EINVAL` for a relative input.
222pub fn resolve(fs: &Fs, pid: u32, guest: &[u8], deref_final: bool) -> Result<Resolved> {
223    resolve_with(fs, pid, guest, deref_final, Missing::Errno)
224}
225
226fn resolve_with(fs: &Fs, pid: u32, guest: &[u8], deref_final: bool, missing: Missing) -> Result<Resolved> {
227    if guest.first() != Some(&b'/') {
228        return Err(libc::EINVAL);
229    }
230    if guest.len() >= PATH_MAX {
231        return Err(libc::ENAMETOOLONG);
232    }
233    for _ in 0..MAX_RESTARTS {
234        match walk(fs, pid, guest, deref_final, missing) {
235            Err(RETRY) => continue,
236            Ok(r) if r.guest.len() >= PATH_MAX => return Err(libc::ENAMETOOLONG),
237            r => return r,
238        }
239    }
240    Err(libc::ELOOP)
241}
242
243/// One pass of [`resolve`]; `Err(RETRY)` when a component changed under it.
244fn walk(fs: &Fs, pid: u32, path: &[u8], deref_final: bool, missing: Missing) -> Result<Resolved> {
245    // The canonical guest directory walked so far, and its pinned descriptor:
246    // `None` while textual (a mount point whose host side is missing), and
247    // stale when `seated` is false (after `..` or a `/proc` rewrite), to be
248    // reopened from the mount point before the next lookup.
249    let mut result: Vec<u8> = b"/".to_vec();
250    let mut cur: Option<Arc<OwnedFd>> = fs.mount_pin(b"/").flatten();
251    let mut seated = true;
252    let mut queue: VecDeque<Vec<u8>> = components(path);
253    let mut hops = 0u32;
254    while let Some(comp) = queue.pop_front() {
255        let is_final = queue.is_empty();
256        match comp.as_slice() {
257            b"." => continue,
258            b".." => {
259                pop_component(&mut result);
260                seated = false;
261                continue;
262            }
263            _ => {}
264        }
265        let candidate = join(&result, &comp);
266        // The tracee's own /proc entries, never the supervisor's.
267        if candidate == b"/proc/self" {
268            result = format!("/proc/{pid}").into_bytes();
269            seated = false;
270            continue;
271        }
272        if candidate == b"/proc/thread-self" {
273            result = format!("/proc/{pid}/task/{pid}").into_bytes();
274            seated = false;
275            continue;
276        }
277        if (!is_final || deref_final) && is_proc_magic_link(&candidate) {
278            // `/proc/<n>/exe` is answered from our own record: the kernel's link
279            // names the dynamic loader after an exec rewrite.  Without ptrace
280            // `/proc/<n>/cwd` is too, since the kernel's is pinned elsewhere.
281            if candidate.ends_with(b"/exe")
282                && let Some(exe) = proc_pid(&candidate).and_then(|n| fs.exe_of(n))
283            {
284                result = exe;
285                seated = false;
286                continue;
287            }
288            if candidate.ends_with(b"/cwd")
289                && let Some(cwd) = proc_pid(&candidate).and_then(|n| fs.virtual_cwd(n))
290            {
291                result = cwd;
292                seated = false;
293                continue;
294            }
295            if fs.proc_is_host {
296                // `fd/N` and `map_files/X` name open objects, not paths: a memfd,
297                // a pipe, a deleted file.  Opening one re-opens the object, which
298                // only the kernel can do, so a final one is left to the kernel
299                // (the link itself is not under the guest's control).  Followed
300                // by more components it must be a directory, whose path the link
301                // names; that is re-entered as a guest path.
302                if is_final && is_proc_fd_link(&candidate) {
303                    return Ok(textual(fs, candidate));
304                }
305                // The link content is a host path (or "pipe:[n]"-style for non-files).
306                let host = fs.substitute_guest_to_host(&candidate);
307                match read_link(&host) {
308                    Ok(target) if target.first() == Some(&b'/') => {
309                        let target = super::translate::strip_deleted(target);
310                        result = fs.substitute_host_to_guest(&target).unwrap_or(target);
311                        if candidate.ends_with(b"/root") {
312                            result = b"/".to_vec();
313                        }
314                        seated = false;
315                        continue;
316                    }
317                    Ok(_) if is_proc_fd_link(&candidate) => return Err(libc::ENOTDIR),
318                    Ok(_) => return Ok(textual(fs, append_rest(candidate, queue.make_contiguous()))),
319                    Err(e) => return Err(e),
320                }
321            }
322        }
323        // A mount point of the view: its own pinned host side, whatever lies
324        // under the name in the directory above.
325        if let Some(pin) = fs.mount_pin(&candidate) {
326            if is_final {
327                return Ok(match pin {
328                    Some(fd) => object(fs, candidate, fd),
329                    None => textual(fs, candidate),
330                });
331            }
332            result = candidate;
333            cur = pin;
334            seated = true;
335            continue;
336        }
337        if !is_final && let Some(fd) = fs.cache_get(&candidate) {
338            result = candidate;
339            cur = Some(fd);
340            seated = true;
341            continue;
342        }
343        if !seated {
344            cur = match open_dir(fs, &result) {
345                Err(libc::ENOENT) if fs.binding_below(&result) => None,
346                Err(libc::ENOENT) if missing == Missing::Lexical => {
347                    return Ok(textual(fs, append_rest(candidate, queue.make_contiguous())));
348                }
349                r => r?,
350            };
351            seated = true;
352        }
353        let Some(dir) = cur.clone() else {
354            // Textual: the mount point this lies under has no host side yet.
355            let host = fs.substitute_guest_to_host(&candidate);
356            let mode = match lstat_mode(&host) {
357                Ok(m) => m,
358                Err(_) if !is_final && fs.binding_below(&candidate) => {
359                    result = candidate;
360                    continue;
361                }
362                Err(_) if is_final => return Ok(textual(fs, candidate)),
363                Err(_) => return Ok(textual(fs, append_rest(candidate, queue.make_contiguous()))),
364            };
365            let is_link = mode & libc::S_IFMT == libc::S_IFLNK;
366            if is_link && (!is_final || deref_final) {
367                let target = read_link(&host)?;
368                follow(&mut hops, &mut result, &mut seated, &mut queue, target)?;
369                continue;
370            }
371            if !is_final && mode & libc::S_IFMT != libc::S_IFDIR {
372                return Err(libc::ENOTDIR);
373            }
374            result = candidate;
375            continue;
376        };
377        if is_final {
378            if !deref_final {
379                return Ok(child(fs, candidate, dir, comp));
380            }
381            match fstatat_mode(&dir, &comp) {
382                Ok(mode) if mode & libc::S_IFMT == libc::S_IFLNK => {
383                    // The link may have been swapped for something else since the
384                    // stat; then this walk is stale.
385                    let target = readlinkat(&dir, &comp).map_err(|_| RETRY)?;
386                    follow(&mut hops, &mut result, &mut seated, &mut queue, target)?;
387                    continue;
388                }
389                // Missing or unreadable: the kernel reports it through the parent.
390                _ => return Ok(child(fs, candidate, dir, comp)),
391            }
392        }
393        match open_dir_component(&dir, &comp) {
394            Ok(fd) => {
395                let fd = Arc::new(fd);
396                fs.cache_put(&candidate, fd.clone());
397                result = candidate;
398                cur = Some(fd);
399            }
400            Err(libc::ELOOP) => {
401                let target = readlinkat(&dir, &comp).map_err(|_| RETRY)?;
402                follow(&mut hops, &mut result, &mut seated, &mut queue, target)?;
403            }
404            // A directory of the view only for the binding below it: on from
405            // its name, to that binding's pin.
406            Err(libc::ENOENT) if fs.binding_below(&candidate) => {
407                result = candidate;
408                cur = None;
409            }
410            Err(libc::ENOENT) if missing == Missing::Lexical => {
411                return Ok(textual(fs, append_rest(candidate, queue.make_contiguous())));
412            }
413            // Missing or not a directory, as the kernel would say of the component.
414            Err(e) => return Err(e),
415        }
416    }
417    // The path names the directory walked to itself: `/`, a mount point, or
418    // a directory reached through `..` or a `/proc` rewrite.
419    if let Some(pin) = fs.mount_pin(&result) {
420        return Ok(match pin {
421            Some(fd) => object(fs, result, fd),
422            None => textual(fs, result),
423        });
424    }
425    let mut parent = result.clone();
426    pop_component(&mut parent);
427    let name = result.rsplit(|&c| c == b'/').next().unwrap_or_default().to_vec();
428    match open_dir(fs, &parent)? {
429        Some(dir) => Ok(child(fs, result, dir, name)),
430        None => Ok(textual(fs, result)),
431    }
432}
433
434/// Re-enters a symlink target into the walk, interpreted in the guest
435/// namespace relative to the canonical directory that holds the link.
436fn follow(
437    hops: &mut u32,
438    result: &mut Vec<u8>,
439    seated: &mut bool,
440    queue: &mut VecDeque<Vec<u8>>,
441    target: Vec<u8>,
442) -> Result<()> {
443    *hops += 1;
444    if *hops > MAX_SYMLINKS {
445        return Err(libc::ELOOP);
446    }
447    if target.first() == Some(&b'/') {
448        *result = b"/".to_vec();
449        *seated = false;
450    }
451    for comp in components(&target).into_iter().rev() {
452        queue.push_front(comp);
453    }
454    Ok(())
455}
456
457/// Opens the canonical guest directory `guest` from the mount point it lies under.
458///
459/// Each component below the mount point is a directory the walk already
460/// resolved, so one that is a symlink now was swapped in since: that is a
461/// [`RETRY`], as the walk must resolve it in the guest namespace.
462///
463/// # Returns
464/// `None` when the mount point has no host side yet (textual lookups).
465fn open_dir(fs: &Fs, guest: &[u8]) -> Result<Option<Arc<OwnedFd>>> {
466    if let Some(fd) = fs.cache_get(guest) {
467        return Ok(Some(fd));
468    }
469    let (pin, rest) = fs.pin_for_guest(guest);
470    let Some(mut dir) = pin else {
471        return Ok(None);
472    };
473    let mut prefix = guest[..guest.len() - rest.len()].to_vec();
474    for comp in rest.split(|&c| c == b'/').filter(|c| !c.is_empty()) {
475        prefix = join(&prefix, comp);
476        if let Some(fd) = fs.cache_get(&prefix) {
477            dir = fd;
478            continue;
479        }
480        dir = Arc::new(open_dir_component(&dir, comp).map_err(|e| if e == libc::ELOOP { RETRY } else { e })?);
481        fs.cache_put(&prefix, dir.clone());
482    }
483    Ok(Some(dir))
484}
485
486/// Opens the directory `comp` inside `dir` as an `O_PATH` descriptor.
487///
488/// # Errors
489/// `ELOOP` when it is a symlink, `ENOTDIR` when it is anything else,
490/// `ENOENT` when it is missing, as `openat2` reports them.
491fn open_dir_component(dir: &OwnedFd, comp: &[u8]) -> Result<OwnedFd> {
492    const RESOLVE_NO_SYMLINKS: u64 = 0x04;
493    #[repr(C)]
494    struct OpenHow {
495        flags: u64,
496        mode: u64,
497        resolve: u64,
498    }
499    let how = OpenHow {
500        flags: (libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC) as u64,
501        mode: 0,
502        resolve: RESOLVE_NO_SYMLINKS,
503    };
504    let c = cstr(comp)?;
505    let fd = unsafe {
506        libc::syscall(
507            libc::SYS_openat2,
508            dir.as_raw_fd(),
509            c.as_ptr(),
510            &how as *const OpenHow,
511            std::mem::size_of::<OpenHow>(),
512        )
513    };
514    if fd < 0 {
515        return Err(errno());
516    }
517    Ok(unsafe { OwnedFd::from_raw_fd(fd as RawFd) })
518}
519
520/// Opens `name` inside `dir` as an `O_PATH|O_CLOEXEC` descriptor with extra `flags`.
521fn open_at(dir: &OwnedFd, name: &[u8], flags: i32) -> Result<OwnedFd> {
522    let c = cstr(name)?;
523    let fd = unsafe {
524        libc::openat(
525            dir.as_raw_fd(),
526            c.as_ptr(),
527            libc::O_PATH | libc::O_CLOEXEC | flags,
528        )
529    };
530    if fd < 0 {
531        return Err(errno());
532    }
533    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
534}
535
536/// A [`Resolved`] for `name` inside the pinned directory `parent`.
537fn child(fs: &Fs, guest: Vec<u8>, parent: Arc<OwnedFd>, name: Vec<u8>) -> Resolved {
538    Resolved {
539        host: fs.substitute_guest_to_host(&guest),
540        guest,
541        at: At::Child { parent, name },
542    }
543}
544
545/// A [`Resolved`] for a pinned mount point itself.
546fn object(fs: &Fs, guest: Vec<u8>, fd: Arc<OwnedFd>) -> Resolved {
547    Resolved {
548        host: fs.substitute_guest_to_host(&guest),
549        guest,
550        at: At::Object(fd),
551    }
552}
553
554/// A [`Resolved`] the kernel resolves from the textual host path.
555fn textual(fs: &Fs, guest: Vec<u8>) -> Resolved {
556    Resolved {
557        host: fs.substitute_guest_to_host(&guest),
558        guest,
559        at: At::Path,
560    }
561}
562
563/// Whether a canonical guest path names a `/proc/<pid>[/task/<tid>]/{exe,cwd,root,fd/N,map_files/X,ns/X}` link.
564///
565/// # Arguments
566/// * `path` - Canonical absolute guest path.
567fn is_proc_magic_link(path: &[u8]) -> bool {
568    let Some(rest) = path.strip_prefix(b"/proc/") else {
569        return false;
570    };
571    let mut parts = rest.split(|&c| c == b'/');
572    let Some(pid) = parts.next() else { return false };
573    if pid.is_empty() || !pid.iter().all(|c| c.is_ascii_digit()) {
574        return false;
575    }
576    let mut parts: Vec<&[u8]> = parts.collect();
577    if parts.len() >= 2 && parts[0] == b"task" && parts[1].iter().all(|c| c.is_ascii_digit()) {
578        parts.drain(0..2);
579    }
580    match parts.as_slice() {
581        [b"exe"] | [b"cwd"] | [b"root"] => true,
582        [b"fd", n] => !n.is_empty() && n.iter().all(|c| c.is_ascii_digit()),
583        [b"map_files", _] | [b"ns", _] => true,
584        _ => false,
585    }
586}
587
588/// Whether a canonical guest path is a `/proc/<pid>[/task/<tid>]/fd/N` or `map_files/X` link.
589fn is_proc_fd_link(path: &[u8]) -> bool {
590    let Some(rest) = path.strip_prefix(b"/proc/") else {
591        return false;
592    };
593    let mut parts: Vec<&[u8]> = rest.split(|&c| c == b'/').collect();
594    if parts.len() >= 3 && parts[1] == b"task" {
595        parts.drain(0..2);
596    } else if !parts.is_empty() {
597        parts.remove(0);
598    }
599    matches!(parts.as_slice(), [b"fd", _] | [b"map_files", _])
600}
601
602/// The `<n>` of a `/proc/<n>/...` path, or of `/proc/<n>/task/<t>/...` (then `t`).
603fn proc_pid(path: &[u8]) -> Option<i32> {
604    let rest = path.strip_prefix(b"/proc/")?;
605    let parts: Vec<&[u8]> = rest.split(|&c| c == b'/').collect();
606    let pick = if parts.len() >= 3 && parts[1] == b"task" {
607        parts[2]
608    } else {
609        parts[0]
610    };
611    std::str::from_utf8(pick).ok()?.parse().ok()
612}
613
614/// The non-empty components of a path, in order.
615fn components(path: &[u8]) -> VecDeque<Vec<u8>> {
616    path.split(|&c| c == b'/')
617        .filter(|c| !c.is_empty())
618        .map(<[u8]>::to_vec)
619        .collect()
620}
621
622/// Appends the remaining components to a path without touching the host.
623fn append_rest(mut path: Vec<u8>, rest: &[Vec<u8>]) -> Vec<u8> {
624    for comp in rest {
625        match comp.as_slice() {
626            b"." => {}
627            b".." => pop_component(&mut path),
628            c => path = join(&path, c),
629        }
630    }
631    path
632}
633
634/// Joins a canonical directory and one component.
635fn join(dir: &[u8], comp: &[u8]) -> Vec<u8> {
636    let mut out = Vec::with_capacity(dir.len() + comp.len() + 1);
637    out.extend_from_slice(dir);
638    if dir != b"/" {
639        out.push(b'/');
640    }
641    out.extend_from_slice(comp);
642    out
643}
644
645/// Removes the last component of a canonical path; `/` stays `/`.
646fn pop_component(path: &mut Vec<u8>) {
647    if let Some(i) = path.iter().rposition(|&c| c == b'/') {
648        path.truncate(i.max(1));
649    }
650}
651
652/// Builds a C string from path bytes.
653fn cstr(path: &[u8]) -> Result<CString> {
654    CString::new(path.to_vec()).map_err(|_| libc::EINVAL)
655}
656
657/// `st_mode` of a host path without following a final symlink.
658fn lstat_mode(host: &[u8]) -> Result<u32> {
659    let c = cstr(host)?;
660    let mut st: libc::stat = unsafe { std::mem::zeroed() };
661    if unsafe { libc::lstat(c.as_ptr(), &mut st) } < 0 {
662        return Err(errno());
663    }
664    Ok(st.st_mode)
665}
666
667/// `st_mode` of `name` inside `dir` without following a final symlink.
668fn fstatat_mode(dir: &OwnedFd, name: &[u8]) -> Result<u32> {
669    let c = cstr(name)?;
670    let mut st: libc::stat = unsafe { std::mem::zeroed() };
671    if unsafe { libc::fstatat(dir.as_raw_fd(), c.as_ptr(), &mut st, libc::AT_SYMLINK_NOFOLLOW) } < 0 {
672        return Err(errno());
673    }
674    Ok(st.st_mode)
675}
676
677/// `st_mode` of an open descriptor.
678fn fstat_mode(fd: &OwnedFd) -> Result<u32> {
679    let mut st: libc::stat = unsafe { std::mem::zeroed() };
680    if unsafe { libc::fstat(fd.as_raw_fd(), &mut st) } < 0 {
681        return Err(errno());
682    }
683    Ok(st.st_mode)
684}
685
686/// `readlinkat(2)` of `name` inside `dir`, as bytes.
687pub fn readlinkat(dir: &OwnedFd, name: &[u8]) -> Result<Vec<u8>> {
688    let c = cstr(name)?;
689    let mut buf = vec![0u8; PATH_MAX];
690    let n = unsafe {
691        libc::readlinkat(
692            dir.as_raw_fd(),
693            c.as_ptr(),
694            buf.as_mut_ptr() as *mut libc::c_char,
695            buf.len(),
696        )
697    };
698    if n < 0 {
699        return Err(errno());
700    }
701    buf.truncate(n as usize);
702    Ok(buf)
703}
704
705/// `readlink(2)` on a host path, as bytes.
706fn read_link(host: &[u8]) -> Result<Vec<u8>> {
707    super::translate::read_link(host)
708}
709
710/// Removes `.` components, resolves `..` lexically and collapses slashes.
711///
712/// # Arguments
713/// * `path` - Absolute path.
714///
715/// # Returns
716/// The cleaned absolute path.
717#[allow(dead_code)]
718pub fn lexical_clean(path: &[u8]) -> Vec<u8> {
719    let mut parts: Vec<&[u8]> = Vec::new();
720    for comp in path.split(|&c| c == b'/') {
721        match comp {
722            b"" | b"." => {}
723            b".." => {
724                parts.pop();
725            }
726            c => parts.push(c),
727        }
728    }
729    let mut out = Vec::with_capacity(path.len() + 1);
730    for p in &parts {
731        out.push(b'/');
732        out.extend_from_slice(p);
733    }
734    if out.is_empty() {
735        out.push(b'/');
736    }
737    out
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use crate::config::{Binding, Config};
744    use std::os::unix::fs::symlink;
745    use std::path::PathBuf;
746    use std::sync::atomic::{AtomicUsize, Ordering};
747
748    static COUNTER: AtomicUsize = AtomicUsize::new(0);
749
750    fn me() -> u32 {
751        std::process::id()
752    }
753
754    /// Builds a throwaway rootfs with a few symlinks and one binding.
755    fn fixture() -> (Fs, PathBuf, PathBuf) {
756        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
757        let base = std::env::temp_dir().join(format!("proot-next-canon-{}-{}", std::process::id(), n));
758        let root = base.join("root");
759        let bind = base.join("bind");
760        std::fs::create_dir_all(root.join("usr/bin")).unwrap();
761        std::fs::create_dir_all(root.join("etc")).unwrap();
762        std::fs::create_dir_all(&bind).unwrap();
763        std::fs::write(root.join("usr/bin/sh"), b"#!").unwrap();
764        std::fs::write(root.join("etc/passwd"), b"root").unwrap();
765        std::fs::write(bind.join("file"), b"bound").unwrap();
766        symlink("usr/bin", root.join("bin")).unwrap(); // relative
767        symlink("/usr/bin", root.join("abs")).unwrap(); // absolute, must stay in the guest
768        symlink("loop", root.join("loop")).unwrap(); // self loop
769        symlink("../../..", root.join("escape")).unwrap(); // clamps at guest /
770        symlink("/mnt/file", root.join("tobind")).unwrap(); // into a binding
771        symlink("/etc/passwd", root.join("usr/bin/pw")).unwrap();
772        let mut c = Config {
773            root: root.as_os_str().as_encoded_bytes().to_vec(),
774            ..Config::default()
775        };
776        c.bindings.push(Binding {
777            host: bind.as_os_str().as_encoded_bytes().to_vec(),
778            guest: b"/mnt".to_vec(),
779            read_only: false,
780        });
781        (Fs::new(&c), root, bind)
782    }
783
784    /// The inode a resolved path's parent and name lead to, read through the pin.
785    fn pinned_inode(r: &Resolved) -> u64 {
786        let mut st: libc::stat = unsafe { std::mem::zeroed() };
787        let (dfd, c, nofollow) = r.at(false).unwrap();
788        let flags = if nofollow { libc::AT_SYMLINK_NOFOLLOW } else { 0 };
789        assert_eq!(unsafe { libc::fstatat(dfd, c.as_ptr(), &mut st, flags) }, 0);
790        st.st_ino
791    }
792
793    fn inode(p: &std::path::Path) -> u64 {
794        use std::os::unix::fs::MetadataExt;
795        std::fs::symlink_metadata(p).unwrap().ino()
796    }
797
798    #[test]
799    fn follows_links_inside_the_guest() {
800        let (fs, _root, _bind) = fixture();
801        assert_eq!(
802            canonicalize(&fs, me(), b"/bin/sh", true).unwrap(),
803            b"/usr/bin/sh".to_vec()
804        );
805        assert_eq!(
806            canonicalize(&fs, me(), b"/abs/sh", true).unwrap(),
807            b"/usr/bin/sh".to_vec()
808        );
809        assert_eq!(canonicalize(&fs, me(), b"/abs", false).unwrap(), b"/abs".to_vec());
810        assert_eq!(
811            canonicalize(&fs, me(), b"/abs", true).unwrap(),
812            b"/usr/bin".to_vec()
813        );
814        assert_eq!(
815            canonicalize(&fs, me(), b"//usr/./bin/../bin/pw", true).unwrap(),
816            b"/etc/passwd".to_vec()
817        );
818        assert_eq!(
819            canonicalize(&fs, me(), b"/usr/bin/pw", false).unwrap(),
820            b"/usr/bin/pw".to_vec()
821        );
822    }
823
824    #[test]
825    fn cannot_escape_and_detects_loops() {
826        let (fs, _root, _bind) = fixture();
827        assert_eq!(
828            canonicalize(&fs, me(), b"/escape/etc/passwd", true).unwrap(),
829            b"/etc/passwd".to_vec()
830        );
831        assert_eq!(
832            canonicalize(&fs, me(), b"/../../etc", true).unwrap(),
833            b"/etc".to_vec()
834        );
835        assert_eq!(canonicalize(&fs, me(), b"/loop", true), Err(libc::ELOOP));
836        assert_eq!(canonicalize(&fs, me(), b"/loop/x", true), Err(libc::ELOOP));
837        assert_eq!(
838            canonicalize(&fs, me(), b"/etc/passwd/x", true),
839            Err(libc::ENOTDIR)
840        );
841        assert_eq!(canonicalize(&fs, me(), b"relative", true), Err(libc::EINVAL));
842    }
843
844    #[test]
845    fn missing_components_get_the_kernels_errors() {
846        let (fs, _root, _bind) = fixture();
847        // A missing final component is the parent's business.
848        assert_eq!(
849            canonicalize(&fs, me(), b"/etc/new", true).unwrap(),
850            b"/etc/new".to_vec()
851        );
852        let r = resolve(&fs, me(), b"/etc/new", false).unwrap();
853        assert!(matches!(&r.at, At::Child { name, .. } if name == b"new"));
854        // A missing intermediate one is ENOENT, whatever follows it, for a
855        // lookup; the binding table keeps the rest lexically.
856        assert_eq!(
857            resolve(&fs, me(), b"/nope/a/../b/./c", true).err(),
858            Some(libc::ENOENT)
859        );
860        assert_eq!(resolve(&fs, me(), b"/nope/..", true).err(), Some(libc::ENOENT));
861        assert_eq!(
862            canonicalize(&fs, me(), b"/nope/a/../b/./c", true).unwrap(),
863            b"/nope/b/c".to_vec()
864        );
865    }
866
867    #[test]
868    fn links_into_bindings_resolve_on_the_binding_host() {
869        let (fs, _root, bind) = fixture();
870        assert_eq!(
871            canonicalize(&fs, me(), b"/tobind", true).unwrap(),
872            b"/mnt/file".to_vec()
873        );
874        let r = resolve(&fs, me(), b"/tobind", true).unwrap();
875        assert_eq!(r.host, bind.join("file").as_os_str().as_encoded_bytes().to_vec());
876        assert_eq!(pinned_inode(&r), inode(&bind.join("file")));
877        assert_eq!(
878            canonicalize(&fs, me(), b"/mnt/../etc", true).unwrap(),
879            b"/etc".to_vec()
880        );
881    }
882
883    #[test]
884    fn roots_and_mount_points_are_the_pinned_objects() {
885        let (fs, root, bind) = fixture();
886        let r = resolve(&fs, me(), b"/", true).unwrap();
887        assert!(r.is_mount_point());
888        assert_eq!(pinned_inode(&r), inode(&root));
889        let r = resolve(&fs, me(), b"/mnt", false).unwrap();
890        assert!(r.is_mount_point());
891        assert_eq!(pinned_inode(&r), inode(&bind));
892        let r = resolve(&fs, me(), b"/usr/bin/..", true).unwrap();
893        assert!(matches!(&r.at, At::Child { name, .. } if name == b"usr"));
894        assert_eq!(pinned_inode(&r), inode(&root.join("usr")));
895    }
896
897    #[test]
898    fn directory_cache_is_used_and_flushed() {
899        let (fs, root, _bind) = fixture();
900        canonicalize(&fs, me(), b"/usr/bin/sh", true).unwrap();
901        assert!(fs.cache_has_dir(b"/usr/bin") || std::env::var_os("PROOT_NO_PATH_CACHE").is_some());
902        // Replace the directory by a symlink behind the cache's back, then flush.
903        std::fs::rename(root.join("usr/bin"), root.join("usr/realbin")).unwrap();
904        symlink("realbin", root.join("usr/bin")).unwrap();
905        fs.flush_cache();
906        assert!(!fs.cache_has_dir(b"/usr/bin"));
907        assert_eq!(
908            canonicalize(&fs, me(), b"/usr/bin/sh", true).unwrap(),
909            b"/usr/realbin/sh".to_vec()
910        );
911    }
912
913    #[test]
914    fn a_swapped_in_symlink_is_seen_not_followed() {
915        let (fs, root, _bind) = fixture();
916        let outside = root.parent().unwrap().join("outside");
917        std::fs::create_dir_all(&outside).unwrap();
918        std::fs::write(outside.join("sh"), b"host").unwrap();
919        // The walk pins usr/bin; the guest then renames it away and plants a
920        // link to a host directory in its place.
921        let r = resolve(&fs, me(), b"/usr/bin/sh", true).unwrap();
922        std::fs::rename(root.join("usr/bin"), root.join("usr/realbin")).unwrap();
923        symlink(&outside, root.join("usr/bin")).unwrap();
924        assert_eq!(pinned_inode(&r), inode(&root.join("usr/realbin/sh")));
925        let (dfd, c, nofollow) = r.at(false).unwrap();
926        assert!(nofollow);
927        let fd = unsafe { libc::openat(dfd, c.as_ptr(), libc::O_RDONLY | libc::O_NOFOLLOW) };
928        assert!(fd >= 0);
929        let mut buf = [0u8; 2];
930        assert_eq!(unsafe { libc::read(fd, buf.as_mut_ptr() as *mut _, 2) }, 2);
931        assert_eq!(&buf, b"#!");
932        unsafe { libc::close(fd) };
933        // Walked afresh, the link is interpreted in the guest, where its target is missing.
934        fs.flush_cache();
935        assert_eq!(resolve(&fs, me(), b"/usr/bin/sh", true).err(), Some(libc::ENOENT));
936    }
937
938    #[test]
939    fn a_binding_below_a_missing_directory_is_reached() {
940        // A rootfs glue could not make the placeholders in has nothing at /opt.
941        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
942        let base = std::env::temp_dir().join(format!("proot-next-canon-below-{}-{}", std::process::id(), n));
943        std::fs::create_dir_all(base.join("root")).unwrap();
944        std::fs::write(base.join("file"), b"bound").unwrap();
945        let mut c = Config {
946            root: base.join("root").as_os_str().as_encoded_bytes().to_vec(),
947            ..Config::default()
948        };
949        c.bindings.push(Binding {
950            host: base.join("file").as_os_str().as_encoded_bytes().to_vec(),
951            guest: b"/opt/x/y/file".to_vec(),
952            read_only: false,
953        });
954        let fs = Fs::new(&c);
955        for path in [&b"/opt/x/y/file"[..], b"/opt/x/../x/y/./file"] {
956            let r = resolve(&fs, me(), path, true).unwrap();
957            assert_eq!(r.guest, b"/opt/x/y/file".to_vec());
958            assert_eq!(pinned_inode(&r), inode(&base.join("file")));
959        }
960        assert_eq!(resolve(&fs, me(), b"/nope/x", true).err(), Some(libc::ENOENT));
961    }
962
963    #[test]
964    fn classifies_proc_magic_links() {
965        for path in [
966            &b"/proc/12/exe"[..],
967            b"/proc/12/fd/3",
968            b"/proc/12/ns/pid",
969            b"/proc/12/task/13/ns/net",
970        ] {
971            assert!(is_proc_magic_link(path), "{}", String::from_utf8_lossy(path));
972        }
973        for path in [&b"/proc/12/ns"[..], b"/proc/12/status", b"/proc/self/ns/pid"] {
974            assert!(!is_proc_magic_link(path), "{}", String::from_utf8_lossy(path));
975        }
976    }
977}