From 0b58f1db893f5d6833796f1b6eadba00cbde11e6 Mon Sep 17 00:00:00 2001 From: Prakash Surya Date: Tue, 28 Apr 2026 09:24:24 -0700 Subject: [PATCH] libspl/mnttab: follow symlinks when resolving path via statx (#18469) When the path argument to "zfs list -Ho name " (or any caller of zfs_path_to_zhandle()) is a symlink that crosses a mount boundary, the wrong dataset is returned. Instead of returning the dataset that owns the symlink's target, getextmntent() matches the dataset containing the symlink itself. For example, given two ZFS datasets "tank/ds1" and "tank/ds2", and a symlink "/tank/ds1/link" pointing into "/tank/ds2": $ sudo zfs list -Ho name /tank/ds1/link tank/ds1 The expected (and previous) behavior is to return "tank/ds2", since the symlink's target resides in that dataset. The problem is in getextmntent(), in lib/libspl/os/linux/mnttab.c. That function calls statx() on the caller-supplied path to obtain its mnt_id (used to match against the mnt_id of each entry in /proc/self/mounts), and it passes AT_SYMLINK_NOFOLLOW to that statx() call. As a result, the mnt_id returned reflects the symlink's location rather than the symlink target's mount, and the wrong /proc/self/mounts entry is matched. The same function also calls stat64() on the caller-supplied path (used as a fallback when STATX_MNT_ID is not available, and to populate the statbuf out-parameter). stat64() always follows symlinks, so the statx() and stat64() calls were inconsistent: one resolved the symlink, the other didn't. The AT_SYMLINK_NOFOLLOW behavior may be appropriate when statx() is called on a mount entry from /proc/self/mounts (which is always a real directory), but it is wrong for caller-supplied paths, which may be symlinks. This bug was introduced by 523d9d6007 ("Validate mountpoint on path-based unmount using statx"), which added the STATX_MNT_ID code path. However, the bug was latent: config/user-statx.m4 omitted "#define _GNU_SOURCE" when checking for STATX_MNT_ID in , so HAVE_STATX_MNT_ID was never defined, and the buggy statx() path was never compiled in. getextmntent() always fell back to the dev_t comparison via stat64(), which correctly follows symlinks. The fix to that autoconf check, in 2b930f63f8 ("config: fix STATX_MNT_ID detection"), caused HAVE_STATX_MNT_ID to be properly defined on kernels that support it, activating the broken AT_SYMLINK_NOFOLLOW path for the first time and exposing the regression. The fix is to drop AT_SYMLINK_NOFOLLOW from the statx() call so that symlinks are followed, matching the behavior of stat64() on the same path. Verified with a minimal reproducer: created two ZFS datasets, placed a symlink inside the first pointing into the second, and confirmed that "zfs list -Ho name " returns the dataset containing the symlink's target rather than the dataset containing the symlink. Signed-off-by: Prakash Surya Reviewed-by: Ameer Hamza Reviewed-by: Mark Maybee Reviewed-by: Alexander Motin --- lib/libspl/os/linux/getmntany.c | 9 ++- tests/runfiles/common.run | 4 ++ tests/zfs-tests/tests/Makefile.am | 3 + .../functional/cli_root/zfs_list/cleanup.ksh | 30 ++++++++ .../functional/cli_root/zfs_list/setup.ksh | 32 +++++++++ .../cli_root/zfs_list/zfs_list_009_pos.ksh | 69 +++++++++++++++++++ 6 files changed, 146 insertions(+), 1 deletion(-) create mode 100755 tests/zfs-tests/tests/functional/cli_root/zfs_list/cleanup.ksh create mode 100755 tests/zfs-tests/tests/functional/cli_root/zfs_list/setup.ksh create mode 100755 tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_009_pos.ksh diff --git a/lib/libspl/os/linux/getmntany.c b/lib/libspl/os/linux/getmntany.c index ee1cdf59b..0e9591c08 100644 --- a/lib/libspl/os/linux/getmntany.c +++ b/lib/libspl/os/linux/getmntany.c @@ -143,7 +143,14 @@ getextmntent(const char *path, struct extmnttab *entry, struct stat64 *statbuf) } #ifdef HAVE_STATX_MNT_ID - if (statx(AT_FDCWD, path, AT_STATX_SYNC_AS_STAT | AT_SYMLINK_NOFOLLOW, + /* + * Use AT_STATX_SYNC_AS_STAT without AT_SYMLINK_NOFOLLOW so that + * symlinks are followed, matching the behavior of stat64() above. + * Without this, if path is a symlink crossing a mount boundary, + * statx() returns the mnt_id of the symlink's location rather + * than the symlink target's mount. + */ + if (statx(AT_FDCWD, path, AT_STATX_SYNC_AS_STAT, STATX_MNT_ID, &stx) == 0 && (stx.stx_mask & STATX_MNT_ID)) { have_mnt_id = B_TRUE; target_mnt_id = stx.stx_mnt_id; diff --git a/tests/runfiles/common.run b/tests/runfiles/common.run index 7d86d2873..69752e07a 100644 --- a/tests/runfiles/common.run +++ b/tests/runfiles/common.run @@ -252,6 +252,10 @@ tests = ['zfs_inherit_001_neg', 'zfs_inherit_002_neg', 'zfs_inherit_003_pos', 'zfs_inherit_mountpoint'] tags = ['functional', 'cli_root', 'zfs_inherit'] +[tests/functional/cli_root/zfs_list] +tests = ['zfs_list_009_pos'] +tags = ['functional', 'cli_root', 'zfs_list'] + [tests/functional/cli_root/zfs_load-key] tests = ['zfs_load-key', 'zfs_load-key_all', 'zfs_load-key_file', 'zfs_load-key_https', 'zfs_load-key_location', 'zfs_load-key_noop', diff --git a/tests/zfs-tests/tests/Makefile.am b/tests/zfs-tests/tests/Makefile.am index 89d945a76..97b644c31 100644 --- a/tests/zfs-tests/tests/Makefile.am +++ b/tests/zfs-tests/tests/Makefile.am @@ -767,6 +767,9 @@ nobase_dist_datadir_zfs_tests_tests_SCRIPTS += \ functional/cli_root/zfs_jail/cleanup.ksh \ functional/cli_root/zfs_jail/setup.ksh \ functional/cli_root/zfs_jail/zfs_jail_001_pos.ksh \ + functional/cli_root/zfs_list/cleanup.ksh \ + functional/cli_root/zfs_list/setup.ksh \ + functional/cli_root/zfs_list/zfs_list_009_pos.ksh \ functional/cli_root/zfs_load-key/cleanup.ksh \ functional/cli_root/zfs_load-key/setup.ksh \ functional/cli_root/zfs_load-key/zfs_load-key_all.ksh \ diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/cleanup.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/cleanup.ksh new file mode 100755 index 000000000..138dfe047 --- /dev/null +++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/cleanup.ksh @@ -0,0 +1,30 @@ +#!/bin/ksh -p +# SPDX-License-Identifier: CDDL-1.0 +# +# CDDL HEADER START +# +# The contents of this file are subject to the terms of the +# Common Development and Distribution License (the "License"). +# You may not use this file except in compliance with the License. +# +# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE +# or https://opensource.org/licenses/CDDL-1.0. +# See the License for the specific language governing permissions +# and limitations under the License. +# +# When distributing Covered Code, include this CDDL HEADER in each +# file and include the License file at usr/src/OPENSOLARIS.LICENSE. +# If applicable, add the following below this CDDL HEADER, with the +# fields enclosed by brackets "[]" replaced with your own identifying +# information: Portions Copyright [yyyy] [name of copyright owner] +# +# CDDL HEADER END +# + +# +# Copyright (c) 2026 by Delphix. All rights reserved. +# + +. $STF_SUITE/include/libtest.shlib + +default_cleanup diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/setup.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/setup.ksh new file mode 100755 index 000000000..912fcfc40 --- /dev/null +++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/setup.ksh @@ -0,0 +1,32 @@ +#!/bin/ksh -p +# SPDX-License-Identifier: CDDL-1.0 +# +# CDDL HEADER START +# +# The contents of this file are subject to the terms of the +# Common Development and Distribution License (the "License"). +# You may not use this file except in compliance with the License. +# +# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE +# or https://opensource.org/licenses/CDDL-1.0. +# See the License for the specific language governing permissions +# and limitations under the License. +# +# When distributing Covered Code, include this CDDL HEADER in each +# file and include the License file at usr/src/OPENSOLARIS.LICENSE. +# If applicable, add the following below this CDDL HEADER, with the +# fields enclosed by brackets "[]" replaced with your own identifying +# information: Portions Copyright [yyyy] [name of copyright owner] +# +# CDDL HEADER END +# + +# +# Copyright (c) 2026 by Delphix. All rights reserved. +# + +. $STF_SUITE/include/libtest.shlib + +verify_runnable "global" + +default_setup $DISKS diff --git a/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_009_pos.ksh b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_009_pos.ksh new file mode 100755 index 000000000..758aa7608 --- /dev/null +++ b/tests/zfs-tests/tests/functional/cli_root/zfs_list/zfs_list_009_pos.ksh @@ -0,0 +1,69 @@ +#!/bin/ksh -p +# SPDX-License-Identifier: CDDL-1.0 +# +# CDDL HEADER START +# +# The contents of this file are subject to the terms of the +# Common Development and Distribution License (the "License"). +# You may not use this file except in compliance with the License. +# +# You can obtain a copy of the license at usr/src/OPENSOLARIS.LICENSE +# or https://opensource.org/licenses/CDDL-1.0. +# See the License for the specific language governing permissions +# and limitations under the License. +# +# When distributing Covered Code, include this CDDL HEADER in each +# file and include the License file at usr/src/OPENSOLARIS.LICENSE. +# If applicable, add the following below this CDDL HEADER, with the +# fields enclosed by brackets "[]" replaced with your own identifying +# information: Portions Copyright [yyyy] [name of copyright owner] +# +# CDDL HEADER END +# + +# +# Copyright (c) 2026 by Delphix. All rights reserved. +# + +. $STF_SUITE/include/libtest.shlib + +# +# DESCRIPTION: +# 'zfs list -Ho name ' follows symlinks when resolving the path to +# a dataset name. A symlink that crosses a mount boundary must resolve to +# the dataset owning the symlink's target, not the dataset containing the +# symlink itself. +# +# STRATEGY: +# 1. Create two child datasets: ds1 and ds2. +# 2. Place a symlink inside ds1 that points into ds2. +# 3. Verify that 'zfs list -Ho name ' returns ds2. +# + +verify_runnable "global" + +DS1="$TESTPOOL/$TESTFS/ds1" +DS2="$TESTPOOL/$TESTFS/ds2" +LINK="$TESTDIR/ds1/link_to_ds2" + +function cleanup +{ + rm -f "$LINK" + datasetexists "$DS1" && log_must zfs destroy "$DS1" + datasetexists "$DS2" && log_must zfs destroy "$DS2" +} + +log_onexit cleanup + +log_assert "'zfs list -Ho name' follows symlinks when resolving a path." + +log_must zfs create "$DS1" +log_must zfs create "$DS2" +log_must ln -s "$TESTDIR/ds2" "$LINK" + +result=$(zfs list -Ho name "$LINK") +if [[ "$result" != "$DS2" ]]; then + log_fail "'zfs list -Ho name $LINK' returned '$result', expected '$DS2'" +fi + +log_pass "'zfs list -Ho name' correctly follows a symlink crossing a mount boundary."