mountpoint(1): new utility, implemented as a stat(1) hardlink

Add mountpoint(1), a simple utility to tell whether the file pointed
to by the argument is a mount point.  It prints whether it is, unless
-q is given, and exits 0 if it is, 1 if it is not, and 2 on error.

The answer comes from the kernel with a single stat(2): the root vnode
of a mounted file system is reported with SFBSD_MNTPOINT in
st_bsdflags.  Unlike comparing realpath(3) of the argument with that
of statfs(2)'s f_mntonname, this works for arbitrarily deep
hierarchies, and after chroot(2) or inside a jail.  A chroot or jail
root is reported as a mount point only if it is one.

The argument does not have to be a directory: file systems such as
nullfs(5) can be mounted over regular files and sockets, and stat(2)
reports those mount points as well.

Since all that is needed is one stat(2) call, make mountpoint a
hardlink to stat(1), the same way readlink(1) is, and document it in
stat.1.

Add ATF tests, including chroot(8) and jail(8) roots that are and are
not mount points, nullfs roots, file systems mounted inside the root,
both from the host and from within the jail, and a nullfs mount of a
file over a file.

Suggested by:	kib
Differential revision:	https://reviews.freebsd.org/D59906
Sponsored by:	Sippy Software, Inc.
MFC after:	2 weeks
This commit is contained in:
Maxim Sobolev committed 2026-10-01 14:25:37 -07:00
1 parent 5f275e5478
commit 43b0384bc7
5 files changed
+511 -5

No files matched your search

+3 -2
View File
@@ -2,8 +2,9 @@
PROG= stat
LINKS= ${BINDIR}/stat ${BINDIR}/readlink
MLINKS= stat.1 readlink.1
LINKS= ${BINDIR}/stat ${BINDIR}/readlink \
${BINDIR}/stat ${BINDIR}/mountpoint
MLINKS= stat.1 readlink.1 stat.1 mountpoint.1
HAS_TESTS=
SUBDIR.${MK_TESTS}+= tests
+58 -3
View File
@@ -29,12 +29,13 @@
.\" ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
.\" POSSIBILITY OF SUCH DAMAGE.
.\"
.Dd September 9, 2025
.Dd October 1, 2026
.Dt STAT 1
.Os
.Sh NAME
.Nm stat ,
.Nm readlink
.Nm readlink ,
.Nm mountpoint
.Nd display file status
.Sh SYNOPSIS
.Nm
@@ -45,6 +46,9 @@
.Nm readlink
.Op Fl fn
.Op Ar
.Nm mountpoint
.Op Fl q
.Ar file
.Sh DESCRIPTION
The
.Nm
@@ -75,9 +79,34 @@ corresponding to
.Ar file .
In this case, the argument does not need to be a symbolic link.
.Pp
When invoked as
.Nm mountpoint ,
the utility checks whether
.Ar file
is the root of a mounted file system.
This is usually a directory, but file systems such as
.Xr nullfs 5
can also be mounted over regular files and sockets.
Symbolic links are followed.
Unless the
.Fl q
option is given, a message stating whether
.Ar file
is a mount point is written to the standard output.
Error messages are written to the standard error even with
.Fl q .
.Pp
The information displayed is obtained by calling
.Xr lstat 2
with the given argument and evaluating the returned structure.
When invoked as
.Nm mountpoint ,
.Xr stat 2
is called instead and the
.Dv SFBSD_MNTPOINT
flag in the
.Fa st_bsdflags
field is checked.
The default format displays the
.Fa st_dev ,
.Fa st_ino ,
@@ -197,6 +226,11 @@ fail.
When run as
.Nm readlink ,
error messages are automatically suppressed.
When run as
.Nm mountpoint ,
.Fl q
suppresses the normal output instead;
error messages are still written to the standard error.
.It Fl f Ar format
Display information using the specified format.
See the
@@ -513,6 +547,12 @@ which default to
.Cm S .
.Sh EXIT STATUS
.Ex -std stat readlink
.Pp
The
.Nm mountpoint
utility exits 0 if
.Ar file
is a mount point, 1 if it is not, and 2 if an error occurred.
.Sh EXAMPLES
If no options are specified, the default format is
"%d %i %Sp %l %Su %Sg %r %z \e"%Sa\e" \e"%Sm\e" \e"%Sc\e" \e"%SB\e" %k %b %#Xf %N".
@@ -620,6 +660,19 @@ To display the same in UTC:
$ TZ= stat -f %Sm -t %Y%m%d%H%M%S /tmp/foo
20070427181533
.Ed
.Pp
To check whether a directory is a mount point:
.Bd -literal -offset indent
\*[Gt] mountpoint /dev
/dev is a mount point
\*[Gt] mountpoint /usr/bin
/usr/bin is not a mount point
.Ed
.Pp
To mount a file system only if nothing is mounted there yet:
.Bd -literal -offset indent
$ mountpoint -q /mnt || mount /dev/md0 /mnt
.Ed
.Sh SEE ALSO
.Xr file 1 ,
.Xr ls 1 ,
@@ -627,7 +680,9 @@ $ TZ= stat -f %Sm -t %Y%m%d%H%M%S /tmp/foo
.Xr readlink 2 ,
.Xr stat 2 ,
.Xr printf 3 ,
.Xr strftime 3
.Xr strftime 3 ,
.Xr nullfs 5 ,
.Xr mount 8
.Sh HISTORY
The
.Nm
+48
View File
@@ -139,6 +139,8 @@
#define SHOW_sizerdev 'Z'
static void usage(const char *);
static int mountpoint_main(int, char *[]);
static void mountpoint_usage(void) __dead2;
static void output(const struct stat *, const char *, const char *, int);
static int format1(const struct stat *, /* stat info */
const char *, /* the file name */
@@ -185,6 +187,9 @@ main(int argc, char *argv[])
statfmt = NULL;
timefmt = NULL;
if (strcmp(getprogname(), "mountpoint") == 0)
return (mountpoint_main(argc, argv));
if (strcmp(getprogname(), "readlink") == 0) {
am_readlink = 1;
options = "fn";
@@ -392,6 +397,49 @@ usage(const char *synopsis)
exit(1);
}
/*
* When invoked as mountpoint, report whether the file is the root of a
* mounted file system. Exit with 0 if it is, 1 if it is not and
* 2 on error.
*/
static int
mountpoint_main(int argc, char *argv[])
{
struct stat st;
int ch, mounted, quiet;
quiet = 0;
while ((ch = getopt(argc, argv, "q")) != -1)
switch (ch) {
case 'q':
quiet = 1;
break;
default:
mountpoint_usage();
}
argc -= optind;
argv += optind;
if (argc != 1)
mountpoint_usage();
if (stat(argv[0], &st) == -1)
err(2, "%s", argv[0]);
mounted = (st.st_bsdflags & SFBSD_MNTPOINT) != 0;
if (!quiet)
printf("%s is %sa mount point\n", argv[0],
mounted ? "" : "not ");
return (mounted ? 0 : 1);
}
static void
mountpoint_usage(void)
{
(void)fprintf(stderr, "usage: %s [-q] file\n", getprogname());
exit(2);
}
/*
* Parses a format string.
*/
+1
View File
@@ -1,3 +1,4 @@
ATF_TESTS_SH+= mountpoint_test
ATF_TESTS_SH+= readlink_test
ATF_TESTS_SH+= stat_test
+401
View File
@@ -0,0 +1,401 @@
#
# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2026 Maksym Sobolev <[email protected]>
#
# Mount a file system and record its mount point for mp_cleanup.
mp_mount()
{
local mntpt
for mntpt; do :; done
[ -e "$mntpt" ] || atf_check mkdir -p "$mntpt"
echo "$PWD/$mntpt" >> mounts
atf_check mount "$@"
}
mp_cleanup()
{
[ -f mounts ] || return 0
tail -r mounts | while read mntpt; do
umount -f "$mntpt" 2>/dev/null
done
return 0
}
# Populate a minimal root with sh, mountpoint and the given programs,
# together with the shared libraries they need.
mp_mkroot()
{
local root lib prog
root=$1
shift
atf_check mkdir -p "$root/bin" "$root/libexec" "$root/mnt"
atf_check cp /libexec/ld-elf.so.1 "$root/libexec/"
for prog in sh mountpoint "$@"; do
prog=$(command -v "$prog") || atf_fail "$prog not found"
atf_check cp "$prog" "$root/bin/"
for lib in $(ldd -f '%p\n' "$prog" | grep '^/'); do
atf_check mkdir -p "$root${lib%/*}"
atf_check cp "$lib" "$root$lib"
done
done
}
mp_require_jail()
{
if [ "$(sysctl -n security.jail.jailed)" != 0 ]; then
atf_skip "Cannot create jails from within a jail"
fi
}
# Run "mountpoint path" inside root, entered via chroot(8) or jail(8)
# as selected by mode, and check that it reports rc. jail(8) does not
# pass the exit status of the command through, so it is echoed back.
mp_check()
{
local mode root path rc not
mode=$1
root=$2
path=$3
rc=$4
not=
[ "$rc" -eq 0 ] || not="not "
case "$mode" in
chroot)
set -- chroot "$root" /bin/sh
;;
jail)
set -- jail -c path="$PWD/$root" command=/bin/sh
;;
esac
atf_check -o inline:"$path is ${not}a mount point\nrc=$rc\n" \
"$@" -c "/bin/mountpoint $path; echo rc=\$?"
}
atf_test_case not_mountpoint
not_mountpoint_head()
{
atf_set "descr" "Verify that a plain directory or file is not " \
"reported as a mount point"
}
not_mountpoint_body()
{
atf_check mkdir dir
atf_check -s exit:1 -o inline:"dir is not a mount point\n" \
mountpoint dir
atf_check -s exit:1 mountpoint -q dir
atf_check touch file
atf_check -s exit:1 -o inline:"file is not a mount point\n" \
mountpoint file
}
atf_test_case errors
errors_head()
{
atf_set "descr" "Verify that errors are reported with exit status 2"
}
errors_body()
{
atf_check -s exit:2 -e match:"No such file" mountpoint nonexistent
atf_check -s exit:2 -e match:"usage" mountpoint
atf_check -s exit:2 -e match:"usage" mountpoint a b
atf_check -s exit:2 -e match:"usage" mountpoint -x .
}
atf_test_case mountpoint cleanup
mountpoint_head()
{
atf_set "descr" "Verify that a mounted file system root is " \
"reported as a mount point, also through a symlink"
atf_set "require.user" "root"
}
mountpoint_body()
{
mp_mount -t tmpfs tmpfs mnt
atf_check -o inline:"mnt is a mount point\n" mountpoint mnt
atf_check -o empty mountpoint -q mnt
atf_check ln -s mnt link
atf_check -o inline:"link is a mount point\n" mountpoint link
atf_check mkdir mnt/sub
atf_check -s exit:1 -o inline:"mnt/sub is not a mount point\n" \
mountpoint mnt/sub
}
mountpoint_cleanup()
{
mp_cleanup
}
atf_test_case file_mountpoint cleanup
file_mountpoint_head()
{
atf_set "descr" "Verify that a regular file with a file system " \
"mounted over it is reported as a mount point"
atf_set "require.user" "root"
atf_set "require.kmods" "nullfs"
}
file_mountpoint_body()
{
atf_check touch src dst
mp_mount -t nullfs "$PWD/src" dst
atf_check -o inline:"dst is a mount point\n" mountpoint dst
atf_check ln -s dst link
atf_check -o inline:"link is a mount point\n" mountpoint link
atf_check -s exit:1 -o inline:"src is not a mount point\n" \
mountpoint src
}
file_mountpoint_cleanup()
{
mp_cleanup
}
# The root is a plain directory on top of a mount point. Neither the
# root nor its ".." may be reported as a mount point, so the mount point
# above the root does not leak in.
root_not_mountpoint_body()
{
mp_mount -t tmpfs tmpfs outer
mp_mkroot outer/root
mp_check $1 outer/root / 1
mp_check $1 outer/root /.. 1
mp_check $1 outer/root /bin 1
}
# The root is itself a mount point.
root_mountpoint_body()
{
mp_mount -t tmpfs tmpfs root
mp_mkroot root
mp_check $1 root / 0
mp_check $1 root /.. 0
mp_check $1 root /bin 1
}
# The root is a nullfs mount, as is common for jails.
root_nullfs_body()
{
mp_mkroot src
mp_mount -t nullfs "$PWD/src" root
mp_check $1 root / 0
mp_check $1 root /bin 1
}
# The root is a plain directory with a file system mounted inside it.
submount_body()
{
mp_mkroot root
mp_mount -t tmpfs tmpfs root/mnt
mp_check $1 root / 1
mp_check $1 root /mnt 0
mp_check $1 root /mnt/.. 1
}
atf_test_case chroot_root_not_mountpoint cleanup
chroot_root_not_mountpoint_head()
{
atf_set "descr" "Verify that a chroot root which is not a mount " \
"point is not reported as one"
atf_set "require.user" "root"
}
chroot_root_not_mountpoint_body()
{
root_not_mountpoint_body chroot
}
chroot_root_not_mountpoint_cleanup()
{
mp_cleanup
}
atf_test_case chroot_root_mountpoint cleanup
chroot_root_mountpoint_head()
{
atf_set "descr" "Verify that a chroot root which is a mount point " \
"is reported as one"
atf_set "require.user" "root"
}
chroot_root_mountpoint_body()
{
root_mountpoint_body chroot
}
chroot_root_mountpoint_cleanup()
{
mp_cleanup
}
atf_test_case chroot_root_nullfs cleanup
chroot_root_nullfs_head()
{
atf_set "descr" "Verify that a chroot root which is a nullfs " \
"mount is reported as a mount point"
atf_set "require.user" "root"
atf_set "require.kmods" "nullfs"
}
chroot_root_nullfs_body()
{
root_nullfs_body chroot
}
chroot_root_nullfs_cleanup()
{
mp_cleanup
}
atf_test_case chroot_submount cleanup
chroot_submount_head()
{
atf_set "descr" "Verify that a file system mounted inside a chroot " \
"is reported as a mount point"
atf_set "require.user" "root"
}
chroot_submount_body()
{
submount_body chroot
}
chroot_submount_cleanup()
{
mp_cleanup
}
atf_test_case jail_root_not_mountpoint cleanup
jail_root_not_mountpoint_head()
{
atf_set "descr" "Verify that a jail root which is not a mount " \
"point is not reported as one"
atf_set "require.user" "root"
atf_set "require.progs" "jail"
}
jail_root_not_mountpoint_body()
{
mp_require_jail
root_not_mountpoint_body jail
}
jail_root_not_mountpoint_cleanup()
{
mp_cleanup
}
atf_test_case jail_root_mountpoint cleanup
jail_root_mountpoint_head()
{
atf_set "descr" "Verify that a jail root which is a mount point " \
"is reported as one"
atf_set "require.user" "root"
atf_set "require.progs" "jail"
}
jail_root_mountpoint_body()
{
mp_require_jail
root_mountpoint_body jail
}
jail_root_mountpoint_cleanup()
{
mp_cleanup
}
atf_test_case jail_root_nullfs cleanup
jail_root_nullfs_head()
{
atf_set "descr" "Verify that a jail root which is a nullfs mount " \
"is reported as a mount point"
atf_set "require.user" "root"
atf_set "require.progs" "jail"
atf_set "require.kmods" "nullfs"
}
jail_root_nullfs_body()
{
mp_require_jail
root_nullfs_body jail
}
jail_root_nullfs_cleanup()
{
mp_cleanup
}
atf_test_case jail_submount cleanup
jail_submount_head()
{
atf_set "descr" "Verify that a file system mounted inside a jail " \
"from the host is reported as a mount point"
atf_set "require.user" "root"
atf_set "require.progs" "jail"
}
jail_submount_body()
{
mp_require_jail
submount_body jail
}
jail_submount_cleanup()
{
mp_cleanup
}
atf_test_case jail_mount_inside cleanup
jail_mount_inside_head()
{
atf_set "descr" "Verify that a file system mounted from within " \
"a jail is reported as a mount point there"
atf_set "require.user" "root"
atf_set "require.progs" "jail"
}
jail_mount_inside_body()
{
local out
mp_require_jail
mp_mkroot root mount umount
echo "$PWD/root/mnt" >> mounts
out="/mnt is a mount point\nrc=0\n/ is not a mount point\nrc=1\n"
atf_check -o inline:"$out" jail -c path="$PWD/root" \
allow.mount allow.mount.tmpfs enforce_statfs=1 \
command=/bin/sh -c "/bin/mount -t tmpfs tmpfs /mnt &&
/bin/mountpoint /mnt; echo rc=\$?;
/bin/mountpoint /; echo rc=\$?;
/bin/umount /mnt"
}
jail_mount_inside_cleanup()
{
mp_cleanup
}
atf_init_test_cases()
{
atf_add_test_case not_mountpoint
atf_add_test_case errors
atf_add_test_case mountpoint
atf_add_test_case file_mountpoint
atf_add_test_case chroot_root_not_mountpoint
atf_add_test_case chroot_root_mountpoint
atf_add_test_case chroot_root_nullfs
atf_add_test_case chroot_submount
atf_add_test_case jail_root_not_mountpoint
atf_add_test_case jail_root_mountpoint
atf_add_test_case jail_root_nullfs
atf_add_test_case jail_submount
atf_add_test_case jail_mount_inside
}