git: 43b0384bc7e8 - main - mountpoint(1): new utility, implemented as a stat(1) hardlink

From: Maxim Sobolev <sobomax_at_FreeBSD.org>
Date: Thu, 01 Oct 2026 21:25:51 UTC
The branch main has been updated by sobomax:

URL: https://cgit.FreeBSD.org/src/commit/?id=43b0384bc7e87df5bd164ce833617e68824f4828

commit 43b0384bc7e87df5bd164ce833617e68824f4828
Author:     Maxim Sobolev <sobomax@FreeBSD.org>
AuthorDate: 2026-10-01 21:25:37 +0000
Commit:     Maxim Sobolev <sobomax@FreeBSD.org>
CommitDate: 2026-10-01 21:25:37 +0000

    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
---
 usr.bin/stat/Makefile                 |   5 +-
 usr.bin/stat/stat.1                   |  61 +++++-
 usr.bin/stat/stat.c                   |  48 ++++
 usr.bin/stat/tests/Makefile           |   1 +
 usr.bin/stat/tests/mountpoint_test.sh | 401 ++++++++++++++++++++++++++++++++++
 5 files changed, 511 insertions(+), 5 deletions(-)

diff --git a/usr.bin/stat/Makefile b/usr.bin/stat/Makefile
index 1694310a7a6b..10eaf16914ea 100644
--- a/usr.bin/stat/Makefile
+++ b/usr.bin/stat/Makefile
@@ -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
diff --git a/usr.bin/stat/stat.1 b/usr.bin/stat/stat.1
index 55e64de0767e..0780b40e11e0 100644
--- a/usr.bin/stat/stat.1
+++ b/usr.bin/stat/stat.1
@@ -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
diff --git a/usr.bin/stat/stat.c b/usr.bin/stat/stat.c
index 0707c762d100..7466212ec72a 100644
--- a/usr.bin/stat/stat.c
+++ b/usr.bin/stat/stat.c
@@ -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.
  */
diff --git a/usr.bin/stat/tests/Makefile b/usr.bin/stat/tests/Makefile
index d48648c55916..02ed0d9409c3 100644
--- a/usr.bin/stat/tests/Makefile
+++ b/usr.bin/stat/tests/Makefile
@@ -1,3 +1,4 @@
+ATF_TESTS_SH+=	mountpoint_test
 ATF_TESTS_SH+=	readlink_test
 ATF_TESTS_SH+=	stat_test
 
diff --git a/usr.bin/stat/tests/mountpoint_test.sh b/usr.bin/stat/tests/mountpoint_test.sh
new file mode 100644
index 000000000000..667eb404c49f
--- /dev/null
+++ b/usr.bin/stat/tests/mountpoint_test.sh
@@ -0,0 +1,401 @@
+#
+# SPDX-License-Identifier: BSD-2-Clause
+#
+# Copyright (c) 2026 Maksym Sobolev <sobomax@FreeBSD.org>
+#
+
+# 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
+}