From 1e4a52e6b4aa48d26bc10ca42fc98e3fee823964 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 16:22:53 +1000 Subject: [PATCH 01/69] vfs: scaffold the virtual-filesystem layer Introduce a vfs/ subtree that will house rsync's filesystem-handling code (the do_* syscall wrappers, the race-safe path resolver, the held-dirfd cache, the operator-path ownership walk and daemon module confinement), separating those security-critical details from the protocol/transfer logic. This first commit only stands up the layer; no code is moved yet, so behavior is identical. - vfs/vfs.h: public interface, included by rsync.h just after proto.h. Declares "struct vfs" -- the single global that will hold the state currently scattered across syscall.c statics (the dirfd cache, curr_dir, operator_path_resolve) and the clientserver.c module_* globals -- plus vfs_init(). - vfs/vfs.c: defines the global instance with a designated initializer so the cache and module snapshot are safe by construction (a plain definition would zero base/module_dirfd, making fd 0 look valid); the t_*_secure harnesses never run main(), so this must not rely on vfs_init(). vfs_init() resets between transfers (inert for now). - Build: bundle the VFS into a static libvfs.a linked last on rsync and every test harness, so later commits can move code out of syscall.o without breaking a harness link (the linker pulls only what it needs). AC_CHECK_TOOL(AR)/AC_PROG_RANLIB added for portable archiving; a vfs/dummy config-file output creates vfs/ in VPATH builds. - main.c calls vfs_init() early (establishes the call site). --- Makefile.in | 60 ++++++++++++++++++++++++++++++++++------------------ configure.ac | 4 +++- main.c | 2 ++ rsync.h | 1 + vfs/dummy.in | 2 ++ vfs/vfs.c | 34 +++++++++++++++++++++++++++++ vfs/vfs.h | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 141 insertions(+), 22 deletions(-) create mode 100644 vfs/dummy.in create mode 100644 vfs/vfs.c create mode 100644 vfs/vfs.h diff --git a/Makefile.in b/Makefile.in index 801e8643e..6e73402e4 100644 --- a/Makefile.in +++ b/Makefile.in @@ -18,6 +18,9 @@ CXXFLAGS=@CXXFLAGS@ EXEEXT=@EXEEXT@ LDFLAGS=@LDFLAGS@ LIBOBJDIR=lib/ +AR=@AR@ +ARFLAGS=cr +RANLIB=@RANLIB@ INSTALLCMD=@INSTALL@ INSTALLMAN=@INSTALL@ @@ -38,7 +41,7 @@ GENFILES=configure.sh aclocal.m4 config.h.in rsync.1 rsync.1.html \ rsync-ssl.1 rsync-ssl.1.html rsyncd.conf.5 rsyncd.conf.5.html \ @GEN_RRSYNC@ HEADERS=byteorder.h config.h errcode.h proto.h rsync.h ifuncs.h itypes.h inums.h \ - lib/pool_alloc.h lib/mdigest.h lib/md-defines.h + lib/pool_alloc.h lib/mdigest.h lib/md-defines.h vfs/vfs.h LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ lib/permstring.o lib/pool_alloc.o lib/sysacls.o lib/sysxattrs.o lib/acl.o @LIBOBJS@ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ @@ -52,9 +55,10 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ +VFS_OBJ=vfs/vfs.o +OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a -TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ +TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a # Programs we must have to run the test cases CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ @@ -67,7 +71,7 @@ CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ # Objects for CHECK_PROGS to clean CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o # Compile-only feature-shape checks. -CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o +CHECK_COMPILE_OBJS=vfs-no-at-fdcwd.o # note that the -I. is needed to handle config.h when using VPATH .c.o: @@ -80,9 +84,18 @@ CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o all: Makefile rsync$(EXEEXT) stunnel-rsyncd.conf @MAKE_RRSYNC@ @MAKE_MAN@ .PHONY: all -syscall-no-at-fdcwd.o: syscall.c $(HEADERS) - $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ - -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/syscall.c -o $@ +# Compile-check the pre-*at() portability tier. syscall.c's *at wrappers were +# split into vfs/, so compile every vfs source with the AT_FDCWD primitives +# undefined (via vfs/vfs_internal.h's RSYNC_TEST_NO_AT_FDCWD block) and confirm +# the fallback arms still build. A shell loop keeps this portable (BSD/Solaris +# make have no pattern rules); the last object compiled is left as the target. +# $(VFS_OBJ:.o=.c) is POSIX suffix substitution, portable across makes. +vfs-no-at-fdcwd.o: $(VFS_OBJ:.o=.c) $(HEADERS) vfs/vfs.h vfs/vfs_internal.h + @for f in $(VFS_OBJ:.o=.c); do \ + echo " no-AT_FDCWD compile-check: $$f"; \ + $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ + -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/$$f -o $@ || exit 1; \ + done .PHONY: install install: all @@ -141,7 +154,16 @@ rrsync: support/rrsync $(OBJS): $(HEADERS) $(CHECK_OBJS): $(HEADERS) +$(VFS_OBJ): $(HEADERS) tls.o xattrs.o: lib/sysxattrs.h + +# The VFS layer is bundled into a static archive linked last on every target so +# that moving filesystem code out of syscall.o never breaks a test harness link +# (the linker pulls only the members each program references). +libvfs.a: $(VFS_OBJ) + rm -f $@ + $(AR) $(ARFLAGS) $@ $(VFS_OBJ) + $(RANLIB) $@ usage.o: version.h latest-year.h help-rsync.h help-rsyncd.h git-version.h default-cvsignore.h loadparm.o: default-dont-compress.h daemon-parm.h @@ -202,23 +224,19 @@ getgroups$(EXEEXT): getgroups.o getfsdev$(EXEEXT): getfsdev.o $(CC) $(CFLAGS) $(LDFLAGS) -o $@ getfsdev.o $(LIBS) -TRIMSLASH_OBJ = trimslash.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o +TRIMSLASH_OBJ = trimslash.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o libvfs.a trimslash$(EXEEXT): $(TRIMSLASH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(TRIMSLASH_OBJ) $(LIBS) -T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o +T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) -T_HASHTABLE_OVERFLOW_OBJ = t_hashtable_overflow.o hashtable.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o -t_hashtable_overflow$(EXEEXT): $(T_HASHTABLE_OVERFLOW_OBJ) - $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_HASHTABLE_OVERFLOW_OBJ) $(LIBS) - T_IWILDMATCH_OBJ = t_iwildmatch.o lib/wildmatch.o t_iwildmatch$(EXEEXT): $(T_IWILDMATCH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_IWILDMATCH_OBJ) $(LIBS) -T_CLEAN_FNAME_OBJ = t_clean_fname.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o +T_CLEAN_FNAME_OBJ = t_clean_fname.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a t_clean_fname$(EXEEXT): $(T_CLEAN_FNAME_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CLEAN_FNAME_OBJ) $(LIBS) @@ -231,23 +249,23 @@ t_clean_fname$(EXEEXT): $(T_CLEAN_FNAME_OBJ) # GNU-make-only; BSD and Solaris make expand it to nothing. t_safe_arg_main.o: main.c $(HEADERS) $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) -Dmain=rsync_unused_main -c $(srcdir)/main.c -o t_safe_arg_main.o -T_SAFE_ARG_OBJ = t_safe_arg.o t_safe_arg_main.o $(OBJS1_NO_MAIN) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ +T_SAFE_ARG_OBJ = t_safe_arg.o t_safe_arg_main.o $(OBJS1_NO_MAIN) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a t_safe_arg$(EXEEXT): $(T_SAFE_ARG_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SAFE_ARG_OBJ) $(LIBS) -T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o +T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) -T_RENAME_SECURE_OBJ = t_rename_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o +T_RENAME_SECURE_OBJ = t_rename_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_rename_secure$(EXEEXT): $(T_RENAME_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_RENAME_SECURE_OBJ) $(LIBS) -T_SYMLINK_SECURE_OBJ = t_symlink_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o +T_SYMLINK_SECURE_OBJ = t_symlink_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_symlink_secure$(EXEEXT): $(T_SYMLINK_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SYMLINK_SECURE_OBJ) $(LIBS) -T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o +T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS) @@ -348,10 +366,10 @@ rrsync.1: support/rrsync.1.md md-convert Makefile .PHONY: clean clean: cleantests - rm -f *~ $(OBJS) $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \ + rm -f *~ $(OBJS) $(VFS_OBJ) libvfs.a $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \ git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \ *.html daemon-parm.h help-*.h default-*.h proto.h proto.h-tstamp - rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda + rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda vfs/*.gcno vfs/*.gcda rm -rf coverage coverage-tcp coverage-all coverage-fallback .PHONY: cleantests diff --git a/configure.ac b/configure.ac index 57cf2828d..98ceb8c87 100644 --- a/configure.ac +++ b/configure.ac @@ -60,6 +60,8 @@ AC_PROG_AWK AC_PROG_EGREP AC_PROG_INSTALL AC_PROG_MKDIR_P +AC_CHECK_TOOL([AR], [ar], [ar]) +AC_PROG_RANLIB AC_SUBST(SHELL) AC_PATH_PROG([PERL], [perl]) AC_PATH_PROG([PYTHON3], [python3]) @@ -1542,7 +1544,7 @@ case "$CC" in ;; esac -AC_CONFIG_FILES([Makefile lib/dummy zlib/dummy popt/dummy shconfig]) +AC_CONFIG_FILES([Makefile lib/dummy zlib/dummy popt/dummy vfs/dummy shconfig]) AC_OUTPUT AC_MSG_RESULT() diff --git a/main.c b/main.c index 6050d0e5b..66becde48 100644 --- a/main.c +++ b/main.c @@ -1814,6 +1814,8 @@ int main(int argc,char *argv[]) raw_argc = argc; raw_argv = argv; + vfs_init(); + raise_fd_limit(); #ifdef HAVE_SIGACTION diff --git a/rsync.h b/rsync.h index b15aa1af6..477033ede 100644 --- a/rsync.h +++ b/rsync.h @@ -1237,6 +1237,7 @@ struct name_num_obj { #ifndef __cplusplus #include "proto.h" +#include "vfs/vfs.h" #endif #ifndef SUPPORT_XATTRS diff --git a/vfs/dummy.in b/vfs/dummy.in new file mode 100644 index 000000000..f39c56ef8 --- /dev/null +++ b/vfs/dummy.in @@ -0,0 +1,2 @@ +This is a dummy file to ensure that the vfs directory gets created +by configure when a VPATH is used. diff --git a/vfs/vfs.c b/vfs/vfs.c new file mode 100644 index 000000000..7175ffa29 --- /dev/null +++ b/vfs/vfs.c @@ -0,0 +1,34 @@ +/* + * vfs/vfs.c - core state for rsync's virtual filesystem layer. + * + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" + +/* The single global VFS state. The designated initializer makes the cache and + * module snapshot safe by construction (a plain definition would zero base/ + * module_dirfd, making fd 0 look like a valid cached dir) -- this matters for + * the t_*_secure test harnesses, which never run main() and so never call + * vfs_init(). */ +struct vfs vfs = { + .dpc = { .base = -1, .anchor = (const char *)-2 }, + .module_dirfd = -1, +}; + +/* Reset the VFS between transfers. Idempotent; re-establishes the same safe + * sentinels as the static initializer. Behaviorally inert until the held- + * dirfd cache and module snapshot are migrated into struct vfs in later + * commits (the live cache is still the dpc_* statics in syscall.c for now). */ +void vfs_init(void) +{ + vfs.dpc.base = -1; + vfs.dpc.anchor = (const char *)-2; + vfs.dpc.depth = 0; + vfs.module_dirfd = -1; +} diff --git a/vfs/vfs.h b/vfs/vfs.h new file mode 100644 index 000000000..01eb87a4d --- /dev/null +++ b/vfs/vfs.h @@ -0,0 +1,60 @@ +/* + * vfs/vfs.h - public interface to rsync's virtual filesystem layer. + * + * The VFS owns the messy, security-critical filesystem details (the do_* + * syscall wrappers, the race-safe path resolver, the held-dirfd cache, the + * operator-path ownership walk and the daemon module confinement) so the + * mainline protocol/transfer code can stay clean. State that used to be + * scattered across syscall.c statics and clientserver.c externs lives in the + * single global "struct vfs vfs" below. + * + * This header is included by rsync.h (just after proto.h) so every translation + * unit sees the vfs_* API. It must not include rsync.h itself. + * + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef RSYNC_VFS_H +#define RSYNC_VFS_H + +/* Max held ancestor-dirfd cache depth (was DPC_MAXDEPTH in syscall.c). */ +#define VFS_DPC_MAXDEPTH 64 + +/* The single global VFS state instance (defined in vfs/vfs.c). + * + * Only curr_dir/curr_dir_len/operator_path_resolve are read by mainline code; + * the dpc cache and the module_* snapshot are VFS-internal (touched only by + * the vfs/ sources) and are documented as such. */ +struct vfs { + char curr_dir[MAXPATHLEN]; /* logical cwd (tracked by change_dir) */ + unsigned int curr_dir_len; + + int operator_path_resolve; /* operator-supplied path resolver mode */ + + /* VFS-INTERNAL: held ancestor-dirfd cache. */ + struct { + const char *anchor; /* anchor path, or sentinel (char *)-2 = none */ + int base; /* owned anchor dir fd, or -1 */ + int fd[VFS_DPC_MAXDEPTH]; /* fd after components 0..i */ + char name[VFS_DPC_MAXDEPTH][256]; /* component names */ + int depth; + } dpc; + + /* VFS-INTERNAL: daemon served-module-root snapshot. */ + const char *module_dir; + unsigned int module_dirlen; + int module_dirfd; /* identity-pinned module root fd, or -1 */ +}; + +extern struct vfs vfs; + +/* Reset the VFS to a safe between-transfers state. Safety at startup comes + * from the static initializer in vfs/vfs.c, not from this call. */ +void vfs_init(void); + +#endif /* RSYNC_VFS_H */ From 366bbf68f063335f980e246f0c0b18cf1620e24e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 16:36:05 +1000 Subject: [PATCH 02/69] vfs: move the dirstack path-walk primitives into vfs/dirstack.c First step of relocating the security core. The component-walk dirfd stack (struct dirstack + the ds_* helpers) and the module-confinement helpers (path_has_dotdot_component, abspath_excluded_by_module, open_anchor_dirfd) move verbatim out of syscall.c into vfs/dirstack.c. The secure resolver (still in syscall.c) and the held-dirfd cache reach them through a new private header vfs/vfs_internal.h. The functions are byte-identical to before; only their linkage changes (the ones syscall.c still calls become non-static; ds_path_push/ ds_path_pop/ds_push stay file-local). struct dirstack and SECURE_OPEN_MAXSYMLINKS now live in vfs_internal.h so both sides see one definition. vfs_internal.h also centralizes the option/daemon externs the VFS internals read. No behavior change. --- Makefile.in | 3 +- syscall.c | 351 +-------------------------------------------- vfs/dirstack.c | 268 ++++++++++++++++++++++++++++++++++ vfs/vfs_internal.h | 59 ++++++++ 4 files changed, 330 insertions(+), 351 deletions(-) create mode 100644 vfs/dirstack.c create mode 100644 vfs/vfs_internal.h diff --git a/Makefile.in b/Makefile.in index 6e73402e4..51131a787 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a @@ -155,6 +155,7 @@ rrsync: support/rrsync $(OBJS): $(HEADERS) $(CHECK_OBJS): $(HEADERS) $(VFS_OBJ): $(HEADERS) +$(VFS_OBJ) syscall.o: vfs/vfs_internal.h tls.o xattrs.o: lib/sysxattrs.h # The VFS layer is bundled into a static archive linked last on every target so diff --git a/syscall.c b/syscall.c index 5e92edca0..41236603b 100644 --- a/syscall.c +++ b/syscall.c @@ -48,6 +48,7 @@ #endif #include "ifuncs.h" +#include "vfs/vfs_internal.h" extern int dry_run; extern int am_root; @@ -66,29 +67,7 @@ extern int am_daemon; extern int am_chrooted; extern int insecure_links; extern int module_id; -extern unsigned int module_dirlen; -extern char *module_dir; -extern int module_dirfd; /* daemon: served module root pinned by identity, or -1 */ -extern char *confine_root; /* --confine-root, or NULL; see confinement_root() */ -extern unsigned int confine_rootlen; -extern char curr_dir[MAXPATHLEN]; /* defined below; fwd-declared for the seed */ -extern int operator_path_resolve; /* defined below; fwd-declared for the exclude check */ -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY -/* Open a trusted absolute anchor directory as an owned dirfd. When the anchor is - * the served module root and the daemon pinned it by identity (module_dirfd), dup - * that fd rather than re-resolving the absolute path with openat(AT_FDCWD, ...) -- - * which re-traverses the module's ancestors as the dropped-privilege module uid - * and EACCESes when the module sits under a non-traversable parent (a 0700 home). - * Functionally identical (same inode), just privilege-drop-safe. Gated like its - * callers (the secure resolver and dpc_dir_fd both require these three). */ -static int open_anchor_dirfd(const char *path) -{ - if (module_dirfd >= 0 && am_daemon && module_dir && strcmp(path, module_dir) == 0) - return dup(module_dirfd); - return openat(AT_FDCWD, path, O_RDONLY | O_DIRECTORY); -} -#endif /* Single gate for whether path resolution must be hardened against * parent-component symlink races (TOCTOU). Used by the do_*_at()/do_*_atfd() @@ -126,118 +105,6 @@ int symlink_optout_allowed(void) return insecure_links; } -/* The root an operator/peer-supplied path must stay under, or NULL when nothing - * is confined. A daemon has the served module; a server launched by a wrapper - * with its own restricted directory (rrsync) gets one from --confine-root. - * - * A daemon never honours --confine-root: module_dir is the boundary there, and - * the option arrives in a peer-supplied argv, so obeying it could only loosen - * the module. */ -static const char *confinement_root(unsigned int *lenp) -{ - if (am_daemon) { - *lenp = module_dirlen; - return module_dir; - } - *lenp = confine_rootlen; - return confine_root; -} - -/* Split the "/proc//fd" prefix off `p`, returning the tail -- "" for - * the pin directory itself, otherwise a string starting with '/'. NULL when `p` - * is not in the fd-pin namespace at all. */ -static const char *fd_pin_tail(const char *p) -{ - const char *s; - - if (strncmp(p, "/proc/", 6) != 0) - return NULL; - s = p + 6; - if (strncmp(s, "self/", 5) == 0) /* "/proc/self/..." */ - s += 4; - else { /* "/proc//..." */ - const char *d = s; - while (*s >= '0' && *s <= '9') - s++; - if (s == d || *s != '/') - return NULL; - } - if (strncmp(s, "/fd", 3) != 0) - return NULL; - s += 3; - return (*s == '\0' || *s == '/') ? s : NULL; -} - -/* An EXACT pin entry, "/proc/self/fd/7" -- the one spelling whose target is what - * confinement must judge. rrsync also writes a pinned parent as - * ".../fd/7/", but the walk resolves the magic link itself and checks the - * components past it, so only the bare entry is resolved here. Requiring all - * digits keeps a planted name like ".../fd/outside-secret" out. */ -static int is_exact_fd_pin(const char *p) -{ - const char *tail = fd_pin_tail(p); - - if (!tail || *tail != '/') - return 0; - for (++tail; *tail >= '0' && *tail <= '9'; tail++) {} - return *tail == '\0' && tail[-1] != '/'; -} - -/* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the - * confinement root, for an operator/peer-supplied path that must stay inside it - * (--partial-dir/--backup-dir/alt-basis/merge files: operator_path_resolve). An - * in-tree symlink owned by uid 0 / the euid is followed by design, so it can - * redirect the resolved target outside the root; this catches that escape. - * - * This is ROOT confinement only. The daemon exclude/filter list is a name-based - * visibility filter, NOT a physical-path boundary: a symlink whose own name is - * not excluded may still resolve into an excluded IN-tree subtree, exactly as in - * stock rsync. The defense for a writable module is `munge symlinks` (see - * rsyncd.conf(5)), not this walk. */ -static int abspath_outside_confinement(const char *abspath) -{ - unsigned int rootlen; - const char *root = confinement_root(&rootlen); - char pinned[MAXPATHLEN]; - - if (!root || !abspath) - return 0; - if (rootlen <= 1) /* root is "/": nothing is outside */ - return 0; - /* An fd pin (rrsync rewrites a validated option path to /proc/self/fd/N so - * no later symlink can redirect it) is spelled outside the root by - * construction. Judge it by what it points AT rather than by its spelling, - * so a pin is neither wrongly refused nor blindly trusted. A pin we cannot - * resolve to an absolute path is refused, not waved through: an unreadable - * pin is exactly the case where we cannot say where the open would land. */ - if (!am_daemon) { - const char *tail = fd_pin_tail(abspath); - if (tail && !*tail) - return 0; /* the pin directory: transit, opens nothing */ - if (is_exact_fd_pin(abspath)) { - ssize_t n = readlink(abspath, pinned, sizeof pinned - 1); - if (n <= 0 || pinned[0] != '/') - return operator_path_resolve ? 1 : 0; - pinned[n] = '\0'; - abspath = pinned; - } - } - if (strncmp(abspath, root, rootlen) == 0 - && (abspath[rootlen] == '\0' || abspath[rootlen] == '/')) - return 0; /* inside: name-based exclude is not a boundary */ - /* Not under the root. An ABSOLUTE walk passes through the root's ancestors - * ("/", "/home", ...) on the way down -- those are not "outside", just - * not-yet-arrived, so allow them. A path that has truly DIVERGED is - * outside: refuse it for an operator/peer path that must stay in the tree - * (operator_path_resolve); other opens (--log-file, --*-from, lock/motd) - * may legitimately live elsewhere. The --insecure-links / "insecure links - * = yes" opt-out short-circuits before we get here. */ - size_t alen = strlen(abspath); - if (alen == 0 - || (strncmp(abspath, root, alen) == 0 && root[alen] == '/')) - return 0; /* ancestor of the root: still descending */ - return operator_path_resolve ? 1 : 0; -} /* Advance the tracked absolute path `abspath` by one resolved component, * normalizing "." and ".." exactly as openat() does so the module-confinement @@ -2747,27 +2614,6 @@ int do_open_nofollow(const char *pathname, int flags) (except for a deliberately re-anchored module path; see below). */ -/* Returns 1 if path has any "/"-separated component that is exactly - * "..", 0 otherwise. Used by secure_relative_open's front-door - * validation to reject ".." inputs (bare "..", "foo/..", "subdir/..") - * for non-re-anchored paths; the walk itself resolves an in-tree ".." - * safely (ds_descend pops to the held parent) for a re-anchored path. */ -static int path_has_dotdot_component(const char *path) -{ - const char *p = path; - - while (*p) { - const char *q; - if (*p == '/') { p++; continue; } - q = p; - while (*q && *q != '/') - q++; - if (q - p == 2 && p[0] == '.' && p[1] == '.') - return 1; - p = q; - } - return 0; -} /* The logical current directory (maintained by change_dir() in util1.c). * Defined here -- rather than in util1.c -- so the test helpers that link @@ -2777,201 +2623,6 @@ char curr_dir[MAXPATHLEN]; unsigned int curr_dir_len; #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) -/* In-tree symlink following for secure_relative_open()'s directory walk (below). - * An early version refused every symlink with O_NOFOLLOW on each component, which - * broke legitimate within-tree directory symlinks (--keep-dirlinks #715, and -aR - * through a symlinked parent); the walk now follows them safely. - * - * A directory walk keeps a stack of the open dirfds from the anchor (index 0, - * borrowed -- not closed here) down to the current directory. Descending into - * a real subdirectory pushes its fd; a ".." in a followed symlink target pops - * back to the already-pinned parent fd rather than re-resolving ".." with - * openat(), so an ancestor renamed mid-walk cannot redirect the climb, and the - * climb can never rise above the anchor (a pop at the anchor returns ELOOP). - * This matches RESOLVE_BENEATH, which allows in-tree ".." that stays beneath the - * root. Absolute symlink targets are refused; symlink hops are bounded. */ -#ifndef SECURE_OPEN_MAXSYMLINKS -#define SECURE_OPEN_MAXSYMLINKS 40 -#endif - -/* Max directory levels held open at once during a single resolve. The walk - * holds one fd per component, so depth is bounded by RLIMIT_NOFILE anyway; a - * fixed array (no malloc/realloc) keeps the stack simple and the static - * analyzer happy. Mirrors DPC_MAXDEPTH's fixed-cap approach. */ -#define DS_MAXDEPTH 1024 - -struct dirstack { - int fds[DS_MAXDEPTH]; /* fds[0] = anchor (borrowed); fds[top] = current dir */ - int top; - /* Absolute path of fds[top], maintained as we descend/pop, for the - * exclude-aware refusal (abspath_outside_confinement). Empty unless the - * caller seeds it with the anchor's absolute path; then a followed symlink - * that redirects the walk into a module-excluded dir is refused. */ - char abspath[MAXPATHLEN]; -}; - -/* Append "/comp" to ds->abspath (no-op if it's unseeded/empty so non-daemon - * callers pay nothing). Returns -1 (ENAMETOOLONG) on overflow. */ -static int ds_path_push(struct dirstack *ds, const char *comp) -{ - size_t al = strlen(ds->abspath); - if (al == 0) - return 0; /* unseeded: tracking disabled for this walk */ - size_t cl = strlen(comp); - if (al + 1 + cl >= sizeof ds->abspath) { - errno = ENAMETOOLONG; - return -1; - } - ds->abspath[al] = '/'; - memcpy(ds->abspath + al + 1, comp, cl + 1); - return 0; -} - -/* Drop the last component of ds->abspath (mirrors a ".." pop). */ -static void ds_path_pop(struct dirstack *ds) -{ - char *slash; - if (!ds->abspath[0]) - return; - slash = strrchr(ds->abspath, '/'); - if (slash && slash != ds->abspath) - *slash = '\0'; -} - -/* Initialise with `anchor` (which may be AT_FDCWD) as the un-owned base. - * Returns int for caller symmetry, but cannot fail (the fd array is inline). */ -static int ds_init(struct dirstack *ds, int anchor) -{ - ds->abspath[0] = '\0'; - ds->fds[0] = anchor; - ds->top = 0; - return 0; -} - -/* Close every pushed fd (but not the borrowed anchor at index 0). */ -static void ds_free(struct dirstack *ds) -{ - while (ds->top > 0) - close(ds->fds[ds->top--]); -} - -static int ds_cur(struct dirstack *ds) -{ - return ds->fds[ds->top]; -} - -static int ds_push(struct dirstack *ds, int fd) -{ - if (ds->top + 1 >= DS_MAXDEPTH) { /* deeper than we'll hold open */ - close(fd); - errno = ENOMEM; - return -1; - } - ds->fds[++ds->top] = fd; - return 0; -} - -/* Detach the current dir as an owned fd the caller must close. At the anchor - * (top 0) the anchor is borrowed, so return a fresh dup of it instead. */ -static int ds_take(struct dirstack *ds) -{ - if (ds->top > 0) - return ds->fds[ds->top--]; - return openat(ds->fds[0], ".", O_RDONLY | O_DIRECTORY); -} - -static int ds_walk_path(struct dirstack *ds, char *path, int *hops); - -/* Descend one path component on the stack: "." stays, ".." pops to the pinned - * parent (ELOOP at the anchor), a real subdirectory is pushed, and an in-tree - * directory symlink is followed by walking its (relative, possibly - * ..-containing) target on the same stack. Returns 0, or -1 with errno set: - * ELOOP for a refused/escaping symlink or a hop overrun, otherwise the - * underlying openat()/readlinkat() errno (ENOENT, a real ENOTDIR, EACCES). */ -static int ds_descend(struct dirstack *ds, const char *part, int *hops) -{ - if (part[0] == '.' && part[1] == '\0') - return 0; /* "." -- no movement */ - if (part[0] == '.' && part[1] == '.' && part[2] == '\0') { - if (ds->top == 0) { /* would rise above the anchor */ - errno = ELOOP; - return -1; - } - close(ds->fds[ds->top--]); /* pop to the held parent fd */ - ds_path_pop(ds); - return 0; - } - - int fd = openat(ds_cur(ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); - if (fd != -1) { /* a real subdirectory */ - if (ds_push(ds, fd) < 0) - return -1; - if (ds_path_push(ds, part) < 0) - return -1; - /* exclude-aware: refuse descending into a module-hidden dir (catches a - * symlink that redirected the walk into an excluded subtree). */ - if (abspath_outside_confinement(ds->abspath)) { - errno = ELOOP; - return -1; - } - return 0; - } - /* O_NOFOLLOW refused a symlink (NOFOLLOW_HIT_SYMLINK: ELOOP on Linux, EMLINK - * on FreeBSD, EFTYPE on NetBSD/OpenBSD), or O_DIRECTORY hit a non-directory - * (ENOTDIR). Either may be a symlink, so fall through to the readlink probe; - * anything else is a hard error. */ - if (errno != ENOTDIR && !NOFOLLOW_HIT_SYMLINK(errno)) { - if (errno == EMFILE || errno == ENFILE) { - /* The resolver holds one dirfd per path component, so a deep path - * can exhaust descriptors where plain open() would not. Hint at - * the fix once -- otherwise "Too many open files" is opaque. */ - static int warned = 0; - if (!warned) { - int e = errno; - warned = 1; - rprintf(FWARNING, "out of file descriptors resolving a deep path;" - " raise the open-file limit (e.g. `ulimit -n`)\n"); - errno = e; - } - } - return -1; - } - int open_errno = errno; - - char buf[MAXPATHLEN]; - ssize_t n = readlinkat(ds_cur(ds), part, buf, sizeof buf - 1); - if (n < 0) { - if (errno == EINVAL) /* not a symlink: a real non-dir */ - errno = open_errno; - return -1; - } - if (n == 0 || (size_t)n >= sizeof buf - 1) { - errno = ELOOP; /* empty or truncated target */ - return -1; - } - buf[n] = '\0'; - if (buf[0] == '/') { /* absolute target: refuse */ - errno = ELOOP; - return -1; - } - if (--(*hops) < 0) { - errno = ELOOP; - return -1; - } - return ds_walk_path(ds, buf, hops); -} - -/* Walk every component of a relative path on the stack (used for the basedir, - * and for a followed symlink's target -- which may contain ".."). */ -static int ds_walk_path(struct dirstack *ds, char *path, int *hops) -{ - char *save = NULL; - for (char *c = strtok_r(path, "/", &save); c; c = strtok_r(NULL, "/", &save)) { - if (ds_descend(ds, c, hops) < 0) - return -1; - } - return 0; -} /* Walk `relpath` confined beneath the borrowed anchor dirfd (which may be * AT_FDCWD) and return the opened leaf fd, or -1. Does NOT close `anchor_fd` -- diff --git a/vfs/dirstack.c b/vfs/dirstack.c new file mode 100644 index 000000000..ca4aa74f0 --- /dev/null +++ b/vfs/dirstack.c @@ -0,0 +1,268 @@ +/* + * vfs/dirstack.c - race-safe component-walk primitives for rsync's VFS. + * + * The dirstack walks a relative path one component at a time, keeping an open + * dirfd for every ancestor from the anchor down, so a parent renamed mid-walk + * cannot redirect the climb (TOCTOU). Plus the module-confinement helpers that + * decide whether a resolved absolute path has escaped the served module root. + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "vfs/vfs_internal.h" + +/* Returns 1 if path has any "/"-separated component that is exactly + * "..", 0 otherwise. Used by secure_relative_open's front-door + * validation to reject ".." inputs (bare "..", "foo/..", "subdir/..") + * for non-re-anchored paths; the walk itself resolves an in-tree ".." + * safely (ds_descend pops to the held parent) for a re-anchored path. */ +int path_has_dotdot_component(const char *path) +{ + const char *p = path; + + while (*p) { + const char *q; + if (*p == '/') { p++; continue; } + q = p; + while (*q && *q != '/') + q++; + if (q - p == 2 && p[0] == '.' && p[1] == '.') + return 1; + p = q; + } + return 0; +} + +/* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the + * serving module's root, for an operator/peer-supplied path that must stay in the + * module (--partial-dir/--backup-dir/alt-basis: operator_path_resolve). An + * in-tree symlink owned by uid 0 / the euid is followed by design, so it can + * redirect the resolved target outside the module; this catches that escape. + * + * This is module-ROOT confinement only. The daemon exclude/filter list is a + * name-based visibility filter, NOT a physical-path boundary: a symlink whose own + * name is not excluded may still resolve into an excluded IN-module subtree, + * exactly as in stock rsync. The defense for a writable module is `munge + * symlinks` (see rsyncd.conf(5)), not this walk. No-op unless we're a daemon. */ +int abspath_excluded_by_module(const char *abspath, int name_is_dir) +{ + (void)name_is_dir; + if (!am_daemon || !abspath || !module_dir) + return 0; + if (module_dirlen <= 1) /* module root is "/": nothing is outside */ + return 0; + if (strncmp(abspath, module_dir, module_dirlen) == 0 + && (abspath[module_dirlen] == '\0' || abspath[module_dirlen] == '/')) + return 0; /* inside the module: name-based exclude is not a boundary */ + /* Not under the module root. An ABSOLUTE walk passes through the module + * root's ancestors ("/", "/home", ...) on the way down -- those are not + * "outside", just not-yet-arrived, so allow them. A path that has truly + * DIVERGED from the module tree is outside: refuse it for an operator/peer + * path that must stay in the module (operator_path_resolve); other daemon + * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. + * The --insecure-links / "insecure links = yes" opt-out short-circuits + * before we get here. */ + size_t alen = strlen(abspath); + if (alen == 0 + || (strncmp(abspath, module_dir, alen) == 0 && module_dir[alen] == '/')) + return 0; /* ancestor of the module root: still descending */ + return operator_path_resolve ? 1 : 0; +} + +#if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) + +/* Open a trusted absolute anchor directory as an owned dirfd. When the anchor is + * the served module root and the daemon pinned it by identity (module_dirfd), dup + * that fd rather than re-resolving the absolute path with openat(AT_FDCWD, ...) -- + * which re-traverses the module's ancestors as the dropped-privilege module uid + * and EACCESes when the module sits under a non-traversable parent (a 0700 home). + * Functionally identical (same inode), just privilege-drop-safe. Gated like its + * callers (the secure resolver and dpc_dir_fd both require these three). */ +int open_anchor_dirfd(const char *path) +{ + if (module_dirfd >= 0 && am_daemon && module_dir && strcmp(path, module_dir) == 0) + return dup(module_dirfd); + return openat(AT_FDCWD, path, O_RDONLY | O_DIRECTORY); +} + +/* Append "/comp" to ds->abspath (no-op if it's unseeded/empty so non-daemon + * callers pay nothing). Returns -1 (ENAMETOOLONG) on overflow. */ +static int ds_path_push(struct dirstack *ds, const char *comp) +{ + size_t al = strlen(ds->abspath); + if (al == 0) + return 0; /* unseeded: tracking disabled for this walk */ + size_t cl = strlen(comp); + if (al + 1 + cl >= sizeof ds->abspath) { + errno = ENAMETOOLONG; + return -1; + } + ds->abspath[al] = '/'; + memcpy(ds->abspath + al + 1, comp, cl + 1); + return 0; +} + +/* Drop the last component of ds->abspath (mirrors a ".." pop). */ +static void ds_path_pop(struct dirstack *ds) +{ + char *slash; + if (!ds->abspath[0]) + return; + slash = strrchr(ds->abspath, '/'); + if (slash && slash != ds->abspath) + *slash = '\0'; +} + +/* Initialise with `anchor` (which may be AT_FDCWD) as the un-owned base. */ +int ds_init(struct dirstack *ds, int anchor) +{ + ds->abspath[0] = '\0'; + ds->cap = 16; + ds->fds = (int*)malloc(ds->cap * sizeof(int)); + if (!ds->fds) + return -1; + ds->fds[0] = anchor; + ds->top = 0; + return 0; +} + +/* Close every pushed fd (but not the borrowed anchor at index 0) and free. */ +void ds_free(struct dirstack *ds) +{ + while (ds->top > 0) + close(ds->fds[ds->top--]); + free(ds->fds); + ds->fds = NULL; +} + +int ds_cur(struct dirstack *ds) +{ + return ds->fds[ds->top]; +} + +static int ds_push(struct dirstack *ds, int fd) +{ + if (ds->top + 1 >= ds->cap) { + int ncap = ds->cap * 2; + int *n = (int*)realloc(ds->fds, ncap * sizeof(int)); + if (!n) { + close(fd); + errno = ENOMEM; + return -1; + } + ds->fds = n; + ds->cap = ncap; + } + ds->fds[++ds->top] = fd; + return 0; +} + +/* Detach the current dir as an owned fd the caller must close. At the anchor + * (top 0) the anchor is borrowed, so return a fresh dup of it instead. */ +int ds_take(struct dirstack *ds) +{ + if (ds->top > 0) + return ds->fds[ds->top--]; + return openat(ds->fds[0], ".", O_RDONLY | O_DIRECTORY); +} + +/* Descend one path component on the stack: "." stays, ".." pops to the pinned + * parent (ELOOP at the anchor), a real subdirectory is pushed, and an in-tree + * directory symlink is followed by walking its (relative, possibly + * ..-containing) target on the same stack. Returns 0, or -1 with errno set: + * ELOOP for a refused/escaping symlink or a hop overrun, otherwise the + * underlying openat()/readlinkat() errno (ENOENT, a real ENOTDIR, EACCES). */ +int ds_descend(struct dirstack *ds, const char *part, int *hops) +{ + if (part[0] == '.' && part[1] == '\0') + return 0; /* "." -- no movement */ + if (part[0] == '.' && part[1] == '.' && part[2] == '\0') { + if (ds->top == 0) { /* would rise above the anchor */ + errno = ELOOP; + return -1; + } + close(ds->fds[ds->top--]); /* pop to the held parent fd */ + ds_path_pop(ds); + return 0; + } + + int fd = openat(ds_cur(ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (fd != -1) { /* a real subdirectory */ + if (ds_push(ds, fd) < 0) + return -1; + if (ds_path_push(ds, part) < 0) + return -1; + /* exclude-aware: refuse descending into a module-hidden dir (catches a + * symlink that redirected the walk into an excluded subtree). */ + if (abspath_excluded_by_module(ds->abspath, 1)) { + errno = ELOOP; + return -1; + } + return 0; + } + /* O_NOFOLLOW refused a symlink (NOFOLLOW_HIT_SYMLINK: ELOOP on Linux, EMLINK + * on FreeBSD, EFTYPE on NetBSD/OpenBSD), or O_DIRECTORY hit a non-directory + * (ENOTDIR). Either may be a symlink, so fall through to the readlink probe; + * anything else is a hard error. */ + if (errno != ENOTDIR && !NOFOLLOW_HIT_SYMLINK(errno)) { + if (errno == EMFILE || errno == ENFILE) { + /* The resolver holds one dirfd per path component, so a deep path + * can exhaust descriptors where plain open() would not. Hint at + * the fix once -- otherwise "Too many open files" is opaque. */ + static int warned = 0; + if (!warned) { + int e = errno; + warned = 1; + rprintf(FWARNING, "out of file descriptors resolving a deep path;" + " raise the open-file limit (e.g. `ulimit -n`)\n"); + errno = e; + } + } + return -1; + } + int open_errno = errno; + + char buf[MAXPATHLEN]; + ssize_t n = readlinkat(ds_cur(ds), part, buf, sizeof buf - 1); + if (n < 0) { + if (errno == EINVAL) /* not a symlink: a real non-dir */ + errno = open_errno; + return -1; + } + if (n == 0 || (size_t)n >= sizeof buf - 1) { + errno = ELOOP; /* empty or truncated target */ + return -1; + } + buf[n] = '\0'; + if (buf[0] == '/') { /* absolute target: refuse */ + errno = ELOOP; + return -1; + } + if (--(*hops) < 0) { + errno = ELOOP; + return -1; + } + return ds_walk_path(ds, buf, hops); +} + +/* Walk every component of a relative path on the stack (used for the basedir, + * and for a followed symlink's target -- which may contain ".."). */ +int ds_walk_path(struct dirstack *ds, char *path, int *hops) +{ + char *save = NULL; + for (char *c = strtok_r(path, "/", &save); c; c = strtok_r(NULL, "/", &save)) { + if (ds_descend(ds, c, hops) < 0) + return -1; + } + return 0; +} + +#endif /* O_NOFOLLOW && O_DIRECTORY && AT_FDCWD */ diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h new file mode 100644 index 000000000..d503c1148 --- /dev/null +++ b/vfs/vfs_internal.h @@ -0,0 +1,59 @@ +/* + * vfs/vfs_internal.h - private interface shared among the vfs/ sources (and, + * during the syscall.c -> vfs/ migration, by syscall.c itself). NOT for use by + * mainline rsync code; the public surface is vfs/vfs.h. + * + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#ifndef RSYNC_VFS_INTERNAL_H +#define RSYNC_VFS_INTERNAL_H + +/* Option/daemon globals the VFS internals read (defined in options.c / + * clientserver.c / syscall.c). Centralized here so each vfs/ source picks them + * up from one place rather than re-declaring them. */ +extern int am_daemon; +extern char *module_dir; +extern unsigned int module_dirlen; +extern int module_dirfd; +extern int operator_path_resolve; + +/* Module-confinement helpers (pure logic, always compiled). */ +int path_has_dotdot_component(const char *path); +int abspath_excluded_by_module(const char *abspath, int name_is_dir); + +#if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) + +#ifndef SECURE_OPEN_MAXSYMLINKS +#define SECURE_OPEN_MAXSYMLINKS 40 +#endif + +/* The component-walk dirfd stack used by the secure resolver: a stack of the + * open dirfds from the anchor (index 0, borrowed) down to the current dir. */ +struct dirstack { + int *fds; /* fds[0] = anchor (borrowed); fds[top] = current dir */ + int top; + int cap; + /* Absolute path of fds[top], maintained as we descend/pop, for the + * exclude-aware refusal (abspath_excluded_by_module). Empty unless the + * caller seeds it with the anchor's absolute path; then a followed symlink + * that redirects the walk into a module-excluded dir is refused. */ + char abspath[MAXPATHLEN]; +}; + +int open_anchor_dirfd(const char *path); +int ds_init(struct dirstack *ds, int anchor); +void ds_free(struct dirstack *ds); +int ds_cur(struct dirstack *ds); +int ds_take(struct dirstack *ds); +int ds_descend(struct dirstack *ds, const char *part, int *hops); +int ds_walk_path(struct dirstack *ds, char *path, int *hops); + +#endif /* O_NOFOLLOW && O_DIRECTORY && AT_FDCWD */ + +#endif /* RSYNC_VFS_INTERNAL_H */ From e1b4784a086c928205f019950647f672f2161ee2 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 16:56:06 +1000 Subject: [PATCH 03/69] vfs: move the secure path resolver into vfs/secure_open.c Relocate the race-safe resolver and its policy gates out of syscall.c into vfs/secure_open.c, and give them the vfs_* public names that the mainline code will use going forward: secure_relative_open -> vfs_resolve_open secure_relative_open_at -> vfs_resolve_open_at secure_relpath_active -> vfs_relpath_active symlink_optout_allowed -> vfs_symlink_optout_allowed secure_walk_at stays file-local. The function bodies are unchanged; the call sites across receiver/sender/generator/flist/util1/clientserver/ main/options and the test harnesses are updated to the new names, and the four entry points are declared in vfs/vfs.h. The resolver's only consumer of am_chrooted in syscall.c left with it, so the now-dead `am_chrooted` is dropped from the do_*_at wrappers' local externs (and the file-scope extern), which is otherwise unused. No behavior change. --- Makefile.in | 2 +- clientserver.c | 6 +- configure.ac | 2 +- flist.c | 14 +- generator.c | 8 +- main.c | 2 +- options.c | 2 +- receiver.c | 59 +++-- sender.c | 36 ++- syscall.c | 571 +++++++-------------------------------------- t_rename_secure.c | 4 +- t_secure_relpath.c | 12 +- t_stub.c | 6 +- util1.c | 38 +-- vfs/dirstack.c | 2 +- vfs/secure_open.c | 362 ++++++++++++++++++++++++++++ vfs/vfs.h | 6 + 17 files changed, 536 insertions(+), 596 deletions(-) create mode 100644 vfs/secure_open.c diff --git a/Makefile.in b/Makefile.in index 51131a787..26516bc1e 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/clientserver.c b/clientserver.c index 0e9aca36a..cbbe62058 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1087,7 +1087,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char * the receiver finish/rename path must still resolve beneath the module * root. This prevents TOCTOU race attacks where an attacker could switch a * directory to a symlink between path validation and file open. Match the - * gate in secure_relpath_active() (syscall.c) -- the protection has nothing + * gate in vfs_relpath_active() (syscall.c) -- the protection has nothing * to do with symlink munging, so a module configured with "munge symlinks = * false" must still get the secure-open path. */ use_secure_symlinks = am_daemon && (!am_chrooted || module_dirlen) @@ -1477,7 +1477,7 @@ int start_daemon(int f_in, int f_out) } /* Deliberately do NOT set am_chrooted here. am_chrooted * gates the per-module symlink-race defenses - * (secure_relative_open() and the do_*_at() wrappers in + * (vfs_resolve_open() and the do_*_at() wrappers in * syscall.c) and means "the kernel is enforcing path * confinement at the module boundary". The daemon chroot * confines path resolution to the daemon-chroot directory, @@ -1486,7 +1486,7 @@ int start_daemon(int f_in, int f_out) * subtrees and a sender-controlled symlink in module A * could redirect a syscall to module B (or to other files * inside the daemon chroot) without the per-module - * defenses. Leave am_chrooted=0 here so secure_relative_open() + * defenses. Leave am_chrooted=0 here so vfs_resolve_open() * still fires for "use chroot = no" modules. */ if (chdir("/") < 0) { rsyserr(FLOG, errno, "daemon chdir(\"/\") failed"); diff --git a/configure.ac b/configure.ac index 98ceb8c87..3ef31129c 100644 --- a/configure.ac +++ b/configure.ac @@ -373,7 +373,7 @@ return SYS_openat2 + (int)how.resolve; if test x"$enable_openat2" != x"no"; then if test x"$rsync_cv_HAVE_OPENAT2" = x"yes"; then AC_DEFINE([HAVE_OPENAT2], 1, - [Define to use Linux openat2(RESOLVE_BENEATH) in secure_relative_open where available.]) + [Define to use Linux openat2(RESOLVE_BENEATH) in vfs_resolve_open where available.]) fi fi diff --git a/flist.c b/flist.c index 9276c65fc..f2eb21438 100644 --- a/flist.c +++ b/flist.c @@ -2015,14 +2015,14 @@ static void interpret_stat_error(const char *fname, int is_dir) #if defined HAVE_FDOPENDIR && defined HAVE_DIRFD /* Open a source directory for scanning confined beneath the transfer root. - * secure_relative_open() does a per-component O_NOFOLLOW walk that refuses a + * vfs_resolve_open() does a per-component O_NOFOLLOW walk that refuses a * parent component raced into a symlink pointing out of the tree; fdopendir() * then turns the held fd into the DIR* the scan reads. This mirrors the * sender's confined content open (sender.c): the directory enumeration must be * confined the same way, or a parent-symlink race (or, for a daemon following * mode, an in-module symlink to outside) lets the scan enumerate an out-of-tree * directory and leak its names/metadata/symlink targets. O_DIRECTORY without - * O_NOFOLLOW makes secure_relative_open() follow in-tree directory symlinks + * O_NOFOLLOW makes vfs_resolve_open() follow in-tree directory symlinks * beneath the anchor and refuse escapes, so this serves both the default * no-follow scan and a daemon's symlink-following scan (see the caller). * Returns NULL with errno set on failure, like opendir(). */ @@ -2056,7 +2056,7 @@ static DIR *secure_opendir(const char *fbuf) errno = ENAMETOOLONG; return NULL; } - dfd = secure_relative_open_at(module_dirfd, *modrel ? modrel : ".", + dfd = vfs_resolve_open_at(module_dirfd, *modrel ? modrel : ".", O_RDONLY | O_DIRECTORY, 0); } else if (*fbuf == '/') { /* An absolute scan path (an absolute --relative / --files-from name, or a @@ -2064,11 +2064,11 @@ static DIR *secure_opendir(const char *fbuf) const char *relp = fbuf; while (*relp == '/') relp++; - dfd = secure_relative_open("/", relp, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open("/", relp, O_RDONLY | O_DIRECTORY, 0); } else { /* Non-daemon (or chrooted) sender: confine beneath the cwd the sender * chdir'd into (the transfer root). */ - dfd = secure_relative_open(NULL, fbuf, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, fbuf, O_RDONLY | O_DIRECTORY, 0); } if (dfd < 0) @@ -2106,7 +2106,7 @@ static void send_directory(int f, struct file_list *flist, char *fbuf, int len, /* Confine the enumeration beneath the transfer root. secure_opendir() * follows in-tree directory symlinks (RESOLVE_BENEATH) and refuses one that * escapes, so it serves both modes: - * - a daemon/hardened sender (secure_relpath_active()) is confined to the + * - a daemon/hardened sender (vfs_relpath_active()) is confined to the * module in EVERY mode -- including -L/--copy-dirlinks/--copy-unsafe- * links, matching the content open (sender_open_copylinks_confined) -- * so a following mode cannot be lured to enumerate outside the module; @@ -2118,7 +2118,7 @@ static void send_directory(int f, struct file_list *flist, char *fbuf, int len, * yes", admin-only) -- or a non-daemon --insecure-links -- uses the legacy * opendir() too, restoring the pre-hardening enumeration (re-opening the * escape; documented). */ - if (f >= 0 && !symlink_optout_allowed() && (secure_relpath_active() + if (f >= 0 && !vfs_symlink_optout_allowed() && (vfs_relpath_active() || !(copy_links || copy_unsafe_links || copy_dirlinks || insecure_links))) d = secure_opendir(fbuf); else diff --git a/generator.c b/generator.c index 7e5ad60a3..165cbe28c 100644 --- a/generator.c +++ b/generator.c @@ -955,7 +955,7 @@ static int copy_altdest_file(const char *src, const char *dest, struct file_stru /* Stat an alternate-basis candidate (basis_dir[j]/fname) for a daemon /./ * inner-module chroot through the secure resolver, so a --compare/copy/link-dest * basis can't reach outside the inner module via a symlinked parent (the kernel - * chroot confines only the outer path). secure_relative_open() refuses a parent + * chroot confines only the outer path). vfs_resolve_open() refuses a parent * that escapes beneath the module root. Plain link_stat() everywhere else -- * the non-chroot daemon sanitizes basis paths already, and a local receiver must * still follow an operator's --link-dest=../backup. */ @@ -976,7 +976,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) * resolver) below. Only when am_root >= 0: link_stat_at() omits the * fake-super %stat xattr that link_stat() folds in, so --fake-super keeps * the plain path (a lower-severity, non-root basis lookup). */ - if (!am_daemon && am_root >= 0 && !symlink_optout_allowed()) { + if (!am_daemon && am_root >= 0 && !vfs_symlink_optout_allowed()) { const char *leaf; int dfd = owner_walk_parent(path, &leaf); int r, e; @@ -1052,7 +1052,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) if (dlen >= sizeof dir) { errno = ENAMETOOLONG; return -1; } memcpy(dir, path, dlen); dir[dlen] = '\0'; - if ((dfd = secure_relative_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0)) < 0) + if ((dfd = vfs_resolve_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0)) < 0) return -1; r = link_stat_at(dfd, slash + 1, stp, 0); e = errno; @@ -1125,7 +1125,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, * symlink raced in after the basis_link_stat() check is still * refused (matching basis_link_stat's !am_daemon gate). A daemon * keeps its stronger module-anchored confinement (do_link_at's - * secure_relpath_active path) -- the ownership walk would follow an + * vfs_relpath_active path) -- the ownership walk would follow an * operator-owned symlink out of the module. */ int hlok, op = !am_daemon; if (op) diff --git a/main.c b/main.c index 66becde48..e120a5e6a 100644 --- a/main.c +++ b/main.c @@ -1782,7 +1782,7 @@ static void unset_env_var(const char *var) } -/* The symlink-race-safe path resolver (secure_relative_open) holds one open +/* The symlink-race-safe path resolver (vfs_resolve_open) holds one open * dirfd per path component while it walks a path, plus an ancestor-dirfd cache * -- far more descriptors than legacy rsync's single open(). On a host with a * low default soft limit (e.g. OpenBSD's 128) a deep tree can hit EMFILE. diff --git a/options.c b/options.c index 0efd02eb0..bcfa6d3ff 100644 --- a/options.c +++ b/options.c @@ -123,7 +123,7 @@ int am_daemon = 0; * clientserver.c. NOT set for the daemon-level "daemon chroot = /X" * chroot: that confines path resolution to /X, but module paths * /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module - * symlink-race defenses (secure_relative_open() / do_*_at() in + * symlink-race defenses (vfs_resolve_open() / do_*_at() in * syscall.c, gated by `am_daemon && !am_chrooted`) must still fire * even when the daemon is inside a daemon chroot. */ int am_chrooted = 0; diff --git a/receiver.c b/receiver.c index 28d663acc..b8022855c 100644 --- a/receiver.c +++ b/receiver.c @@ -87,7 +87,7 @@ static int updating_basis_or_equiv; /* Open a basis/output path that may legitimately be an operator-trusted * ABSOLUTE path -- e.g. an absolute --partial-dir ("a directory reserved for - * partial-dir work") or --backup-dir. secure_relative_open() deliberately + * partial-dir work") or --backup-dir. vfs_resolve_open() deliberately * rejects an absolute relpath, so feeding it the whole absolute partialptr * (with a NULL basedir) returns EINVAL: the basis fd is then -1, no basis is * mapped, and receive_data() omits every matched block from the whole-file @@ -121,7 +121,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags /* A peer-supplied --partial-dir basis/staging path (operator_path_resolve set * by recv_files) may be absolute (module_dir-prefixed on a non-chroot daemon) - * and traverse a symlink the secure_relative_open path can't confine: resolve + * and traverse a symlink the vfs_resolve_open path can't confine: resolve * it with the ownership walk, which follows a uid0/euid-owned symlink but * refuses a foreign one AND (via abspath_excluded_by_module) refuses a target * the module's exclude hides -- closing the partial-dir exclude bypass. */ @@ -147,7 +147,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags * "use chroot = yes" makes the kernel root the boundary, so there an alt-dest * basis like --link-dest=../01 must resolve against the cwd as a bare open did * before the hardening (confining it would reject the legitimate sibling - * "..", #915). The re-anchoring in secure_relative_open() covers the + * "..", #915). The re-anchoring in vfs_resolve_open() covers the * in-module ".." climb for the inner-module case too. */ if (!am_daemon || (am_chrooted && !module_dirlen)) { if (basedir) { @@ -178,9 +178,9 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags dirbuf[dlen] = '\0'; dir = dirbuf; } - return secure_relative_open(dir, leaf, flags, mode); + return vfs_resolve_open(dir, leaf, flags, mode); } - return secure_relative_open(basedir, relpath, flags, mode); + return vfs_resolve_open(basedir, relpath, flags, mode); } /* Keep the ownership policy for every attempt to open a one-inplace partial @@ -418,14 +418,14 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) * access to ensure that there is no race condition. They will be * correctly updated after the right owner and group info is set. * (Thanks to snabb@epipe.fi for pointing this out.) */ - /* For any non-chrooted receiver (secure_relpath_active()), create the + /* For any non-chrooted receiver (vfs_relpath_active()), create the * temp file securely so a parent-symlink race can't redirect it. When * the temp lives in the entry's own dir (the common case, no --temp-dir) * use the cached held dir fd; otherwise fall back to secure_mkstemp. An * operator-supplied --temp-dir (tmpdir) gets the ownership-walk resolver * (it may legitimately point outside the tree); the deep-entry-dir fallback, * when the held-dirfd cache declines, gets the strict transfer-path one. */ - if (secure_relpath_active()) { + if (vfs_relpath_active()) { int dfd = held_dfd_for(fnametmp, file); if (dfd >= 0) { char *slash = strrchr(fnametmp, '/'); @@ -1194,33 +1194,56 @@ int recv_files(int f_in, int f_out, char *local_name) /* We now check to see if we are writing the file "inplace" */ if (inplace || one_inplace) { fnametmp = one_inplace ? partialptr : fname; - /* For any non-chrooted receiver (secure_relpath_active()), + /* For any non-chrooted receiver (vfs_relpath_active()), * use secure open to prevent symlink race attacks where an * attacker could switch a directory to a symlink between * path validation and file open. */ /* one_inplace stages into the operator/peer --partial-dir path: * resolve it with the ownership walk (exclude-aware) so it can't be * redirected through a symlink into an excluded subtree. */ - if (secure_relpath_active()) - fd2 = secure_recv_open(fnametmp, O_WRONLY|O_CREAT, 0600, - one_inplace); + if (one_inplace) + operator_path_resolve = 1; + if (vfs_relpath_active()) + fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); else fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); #ifdef linux if (fd2 == -1 && errno == EACCES) { /* Maybe the error was due to protected_regular setting? */ - if (use_secure_symlinks || one_inplace) - fd2 = secure_recv_open(fnametmp, O_WRONLY, 0600, - one_inplace); + if (use_secure_symlinks) + fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); else fd2 = do_open(fnametmp, O_WRONLY, 0600); } #endif if (fd2 == -1 && errno == EACCES) { - /* Temporarily add owner-write access only long enough to open - * a writable descriptor; the helper restores the old mode - * before any network data is consumed, including on failure. */ - fd2 = open_readonly_inplace(fnametmp, one_inplace); + /* A read-only existing file: make it writable, then retry + * (its mode is restored after the transfer). On a + * non-chroot daemon fchmod() a no-follow fd rather than + * chmod the path, so a symlink raced into fnametmp can't + * redirect the chmod (do_chmod_at follows the final link). */ + int errno_save = errno, chmod_ok; + if (use_secure_symlinks) { +#ifdef O_NOFOLLOW + int cfd = vfs_resolve_open(NULL, fnametmp, O_RDONLY|O_NOFOLLOW, 0); + chmod_ok = cfd != -1 && fchmod(cfd, 0600) == 0; + if (cfd != -1) + close(cfd); +#else + /* Without O_NOFOLLOW the resolver's oldest fallback would + * follow a raced symlink, so fail closed rather than + * chmod through it. */ + chmod_ok = 0; +#endif + } else + chmod_ok = do_chmod_at(fnametmp, 0600) == 0; + if (chmod_ok) { + if (use_secure_symlinks) + fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); + else + fd2 = do_open(fnametmp, O_WRONLY, 0600); + } else + errno = errno_save; } if (fd2 == -1) { rsyserr(FERROR_XFER, errno, "open %s failed", diff --git a/sender.c b/sender.c index bb1b137d0..1a6c97442 100644 --- a/sender.c +++ b/sender.c @@ -141,7 +141,7 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, return dup(dfd); if (errno != 0) return -1; - return secure_relative_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0); + return vfs_resolve_open(NULL, dir, O_RDONLY | O_DIRECTORY, 0); } errno = 0; /* top-level file: no parent component to confine */ return -1; @@ -176,9 +176,9 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, } memcpy(dir, relp, dlen); dir[dlen] = '\0'; - dfd = secure_relative_open(module_dir, dir, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(module_dir, dir, O_RDONLY | O_DIRECTORY, 0); } else - dfd = secure_relative_open(module_dir, "", O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(module_dir, "", O_RDONLY | O_DIRECTORY, 0); /* The leaf is the same last component either way; take it from the caller's * persistent fname buffer, not the local secure_path. */ @@ -206,7 +206,7 @@ static int secure_remove_source_file(int dfd, const char *bname) /* Open `relpath` (relative to `anchor`: NULL=cwd, else an absolute trusted root) * with `flags`, opening the leaf via the shared held ancestor-dirfd stack * (held_dir_path_fd) so a directory is walked once, not once per file. The leaf - * semantics are identical to secure_relative_open() -- it always O_NOFOLLOWs a + * semantics are identical to vfs_resolve_open() -- it always O_NOFOLLOWs a * file leaf and folds in O_NOATIME, both preserved here. An uncacheable path * (held_dir_path_fd returns -1) falls back to the full confined walk. */ static int sender_open_confined(const char *anchor, const char *relpath, int flags) @@ -239,12 +239,12 @@ static int sender_open_confined(const char *anchor, const char *relpath, int fla #endif dfd = held_dir_path_fd(anchor, dir); if (dfd < 0) - return secure_relative_open(anchor, relpath, flags | O_NOFOLLOW, 0); + return vfs_resolve_open(anchor, relpath, flags | O_NOFOLLOW, 0); return openat(dfd, bname, flags | O_NOFOLLOW, 0); #else - /* No *at() support: secure_relative_open is a plain open() here (no walk, + /* No *at() support: vfs_resolve_open is a plain open() here (no walk, * so nothing to amortise); use it directly to keep the anchor semantics. */ - return secure_relative_open(anchor, relpath, flags | O_NOFOLLOW, 0); + return vfs_resolve_open(anchor, relpath, flags | O_NOFOLLOW, 0); #endif } @@ -253,7 +253,7 @@ static int sender_open_confined(const char *anchor, const char *relpath, int fla * O_NOFOLLOW that sender_open_confined() applies refuses an in-tree symlink the * operator explicitly asked to follow, so resolve the link ourselves: read it, * refuse an absolute or "../"-escaping target (a module escape), and re-resolve - * the relative target through secure_relative_open() -- which follows in-tree + * the relative target through vfs_resolve_open() -- which follows in-tree * links and rejects an escape above the anchor -- looping for a symlink chain. * The final open is still O_NOFOLLOW, so a raced flip at the resolved leaf is * refused. This keeps the module boundary while honouring --copy-links. */ @@ -287,17 +287,7 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat dir[0] = '\0'; bname = cur; } - /* anchor is checked explicitly: the resolver treats a NULL anchor as - * "relative to cwd", so it is a legal argument for the else branch -- - * only this branch would hand it to strcmp(). */ - if (am_daemon && module_dirfd >= 0 && module_dir && anchor - && strcmp(anchor, module_dir) == 0) - pdfd = secure_relative_open_at_beneath(module_dirfd, dir, - O_RDONLY | O_DIRECTORY, 0); - else - pdfd = secure_relative_open(anchor, dir, - O_RDONLY | O_DIRECTORY, 0); - if (pdfd < 0) + if ((pdfd = vfs_resolve_open(anchor, dir, O_RDONLY | O_DIRECTORY, 0)) < 0) return -1; n = do_readlink_atfd(pdfd, bname, tgt, sizeof tgt - 1); e = errno; @@ -327,7 +317,7 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat errno = ELOOP; return -1; #else - return secure_relative_open(anchor, relpath, O_RDONLY | O_NOFOLLOW, 0); + return vfs_resolve_open(anchor, relpath, O_RDONLY | O_NOFOLLOW, 0); #endif } @@ -647,13 +637,13 @@ void send_files(int f_in, int f_out) exit_cleanup(RERR_PROTOCOL); } - if (symlink_optout_allowed()) { + if (vfs_symlink_optout_allowed()) { /* Module opted out of symlink confinement ("insecure links = * yes", admin-only) -- or a non-daemon --insecure-links: legacy * unconfined open, restoring the pre-hardening content read * (re-opening the escape for that module; documented). */ fd = do_open_checklinks(fname); - } else if (secure_relpath_active()) { + } else if (vfs_relpath_active()) { /* Open from module root to prevent TOCTOU race where * change_pathname's chdir follows a directory symlink. * Reconstruct the full path relative to module_dir @@ -671,7 +661,7 @@ void send_files(int f_in, int f_out) } /* A module with `path = /` makes F_PATHNAME absolute, so the * joined path starts with '/'; strip leading slashes to a - * module-relative path that secure_relative_open accepts (#897). */ + * module-relative path that vfs_resolve_open accepts (#897). */ relp = secure_path; while (*relp == '/') relp++; diff --git a/syscall.c b/syscall.c index 41236603b..5cfbfd8e2 100644 --- a/syscall.c +++ b/syscall.c @@ -22,17 +22,7 @@ #include "rsync.h" -/* Exercise the pre-*at() portability tier on modern build hosts. */ -#ifdef RSYNC_TEST_NO_AT_FDCWD -#undef AT_FDCWD -#undef AT_SYMLINK_NOFOLLOW -#undef HAVE_LINKAT -#undef HAVE_OPENAT2 -#undef HAVE_UTIMENSAT -#undef O_RESOLVE_BENEATH -#endif - -#if !defined MKNOD_CREATES_SOCKETS && defined HAVE_SYS_UN_H +#ifdef HAVE_SYS_UN_H #include /* for the socket+bind() fallback in do_mknod() */ #endif #ifdef HAVE_SYS_ATTR_H @@ -64,46 +54,10 @@ extern int open_noatime; extern int copy_links; extern int copy_unsafe_links; extern int am_daemon; -extern int am_chrooted; extern int insecure_links; extern int module_id; -/* Single gate for whether path resolution must be hardened against - * parent-component symlink races (TOCTOU). Used by the do_*_at()/do_*_atfd() - * wrappers and the receiver's secure-open/secure-mkstemp choices. Hardens - * every non-chrooted receiver (a chroot is its own confinement); the sender is - * excluded so it still follows -L/--copy-links symlinks. A daemon chroot with - * an inner-module /./ boundary still needs these checks because the kernel - * chroot confines the outer path, not the inner module. */ -int secure_relpath_active(void) -{ - /* The "insecure links" / --insecure-links opt-out restores the legacy - * follow-any-symlink behaviour uniformly, so it disables the secure - * resolver on the RECEIVER side too (not just the sender enumeration that - * already checks symlink_optout_allowed()). Without this an opted-out - * module still confined receiver writes/stats through a pre-existing - * in-module symlink -- failing to match the pre-3.4.3 behaviour the opt-out - * promises (documented in rsyncd.conf(5) "munge symlinks"/"insecure links"). */ - if (symlink_optout_allowed()) - return 0; - if (am_daemon && am_chrooted && module_dirlen) - return 1; - return !am_chrooted && (am_daemon || !am_sender); -} - -/* Whether the operator-supplied-path symlink confinement is opted out. For a - * non-daemon transfer this is the local --insecure-links flag. For a daemon it - * is governed ONLY by the module's "insecure links" config (lp_insecure_links) - * -- never by a peer-supplied --insecure-links (a client cannot disable a - * daemon's confinement; the daemon also drops a connection that sends it). So a - * forwarded flag is structurally inert here. */ -int symlink_optout_allowed(void) -{ - if (am_daemon) - return module_id >= 0 && lp_insecure_links(module_id); - return insecure_links; -} /* Advance the tracked absolute path `abspath` by one resolved component, @@ -165,7 +119,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz /* Opted out (local --insecure-links, or a daemon module with "insecure * links = yes"): restore the legacy symlink-following open. */ - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return open(path, flags, mode); const uid_t trusted_uid = geteuid(); @@ -516,7 +470,7 @@ int do_unlink(const char *path) the comment on do_chmod_at() for the threat model. unlink() resolves parent components, so a parent-symlink swap can delete an outside file under the daemon's authority. Defence: open the parent of path - under secure_relative_open() and use unlinkat() (flags=0) against + under vfs_resolve_open() and use unlinkat() (flags=0) against that dirfd. Falls through to do_unlink() for the same dry-run / non-daemon / @@ -525,7 +479,7 @@ int do_unlink(const char *path) int do_unlink_at(const char *path) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -538,7 +492,7 @@ int do_unlink_at(const char *path) #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return unlink(path); dfd = owner_walk_parent(path, &bname); if (dfd < 0) @@ -551,7 +505,7 @@ int do_unlink_at(const char *path) } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return unlink(path); if (!path || !*path || *path == '/') @@ -570,7 +524,7 @@ int do_unlink_at(const char *path) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -615,7 +569,7 @@ int do_symlink(const char *lnk, const char *path) the comment on do_chmod_at() for the threat model. For a real symlink only the parent directory of `path` needs protection -- symlinkat() does not resolve the final component (it creates it). Defence: open - the parent of `path` under secure_relative_open() and call symlinkat() + the parent of `path` under vfs_resolve_open() and call symlinkat() against that dirfd; a top-level (no-slash) path has no parent to confine, so it uses AT_FDCWD directly. The link target string `lnk` is stored verbatim and not resolved at creation time, so it doesn't need @@ -632,7 +586,7 @@ int do_symlink(const char *lnk, const char *path) int do_symlink_at(const char *lnk, const char *path) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -648,7 +602,7 @@ int do_symlink_at(const char *lnk, const char *path) /* Operator path (e.g. an absolute --backup-dir): confine the * parent with the ownership walk, then fall through to the shared * leaf-creation below so fake-super emulation is preserved. */ - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_symlink(lnk, path); dfd = owner_walk_parent(path, &bname); if (dfd < 0) @@ -657,13 +611,13 @@ int do_symlink_at(const char *lnk, const char *path) } else #endif { - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_symlink(lnk, path); if (!path || !*path || *path == '/') return do_symlink(lnk, path); - /* A path with a slash needs secure_relative_open to confine its + /* A path with a slash needs vfs_resolve_open to confine its * parent; a top-level path is in CWD (AT_FDCWD), no parent to * subvert. The leaf is protected below either way (symlinkat() * won't follow it; the fake-super openat() uses O_NOFOLLOW). */ @@ -677,7 +631,7 @@ int do_symlink_at(const char *lnk, const char *path) memcpy(dirpath, path, dlen); dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; owns = True; @@ -795,7 +749,7 @@ int do_link(const char *old_path, const char *new_path) the module, or hard-link an outside file into the module (read disclosure). - Defence: open each parent under secure_relative_open() and use + Defence: open each parent under vfs_resolve_open() and use linkat() between the two dirfds, reusing one when the parents match. flags=0 matches the existing do_link() (don't follow a symbolic-link old_path). Only available on systems with linkat(); @@ -804,7 +758,7 @@ int do_link(const char *old_path, const char *new_path) int do_link_at(const char *old_path, const char *new_path) { #if defined AT_FDCWD && defined HAVE_LINKAT - extern int am_daemon, am_chrooted; + extern int am_daemon; char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; const char *old_slash, *new_slash; @@ -816,7 +770,7 @@ int do_link_at(const char *old_path, const char *new_path) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_link(old_path, new_path); if (!old_path || !*old_path || !new_path || !*new_path) @@ -826,7 +780,7 @@ int do_link_at(const char *old_path, const char *new_path) /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_link(old_path, new_path); old_dfd = owner_walk_parent(old_path, &old_bname); if (old_dfd < 0) @@ -852,7 +806,7 @@ int do_link_at(const char *old_path, const char *new_path) /* Resolve each path's parent dir independently. A path without a * slash lives in CWD (AT_FDCWD), no parent open required. A path - * with a slash needs secure_relative_open to confine its parent + * with a slash needs vfs_resolve_open to confine its parent * resolution -- otherwise a parent symlink (e.g. --link-dest=cd * where cd -> /outside) lets the kernel-level linkat(AT_FDCWD, * "cd/target.txt", ...) escape the module. An absolute path uses @@ -879,7 +833,7 @@ int do_link_at(const char *old_path, const char *new_path) memcpy(old_dirpath, old_path, old_dlen); old_dirpath[old_dlen] = '\0'; old_bname = old_slash + 1; - old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); + old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); if (old_dfd < 0) return -1; old_owns = True; @@ -918,7 +872,7 @@ int do_link_at(const char *old_path, const char *new_path) && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { new_dfd = old_dfd; } else { - new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); + new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); if (new_dfd < 0) { e = errno; if (old_owns) close(old_dfd); @@ -960,7 +914,7 @@ int do_lchown(const char *path, uid_t owner, gid_t group) Symlink-race-safe variant of do_lchown() for receiver-side use. See the comment on do_chmod_at() for the threat model and design rationale. - Resolves the parent directory under secure_relative_open() and invokes + Resolves the parent directory under vfs_resolve_open() and invokes fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an attacker who substitutes a symlink into one of the parent components cannot redirect the chown outside the receiver's confinement. The @@ -972,8 +926,8 @@ int do_lchown(const char *path, uid_t owner, gid_t group) */ int do_lchown_at(const char *fname, uid_t owner, gid_t group) { -#if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW - extern int am_daemon, am_chrooted; +#ifdef AT_FDCWD + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -983,26 +937,7 @@ int do_lchown_at(const char *fname, uid_t owner, gid_t group) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path: resolve the parent via the ownership walk, as - * the other do_*_at() wrappers do. Without this the caller's - * operator_path_resolve has no effect here, and an absolute name would - * fall straight through to the unconfined full-path do_lchown(). */ - if (operator_path_resolve && fname && *fname) { - if (symlink_optout_allowed()) - return do_lchown(fname, owner, group); - dfd = owner_walk_parent(fname, &bname); - if (dfd < 0) - return -1; - ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_lchown(fname, owner, group); if (!fname || !*fname || *fname == '/') @@ -1021,7 +956,7 @@ int do_lchown_at(const char *fname, uid_t owner, gid_t group) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -1099,7 +1034,7 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) /* Symlink-race-safe variant of do_mknod() for receiver-side use. See the comment on do_chmod_at() for the threat model. Defence: open - the parent of pathname under secure_relative_open() and use + the parent of pathname under vfs_resolve_open() and use mknodat() against that dirfd. mknodat() covers both regular-file (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. @@ -1122,7 +1057,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so * the at-variant won't build there; fall back to do_mknod() (#896). */ #if defined(AT_FDCWD) && defined(HAVE_MKNODAT) - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1135,7 +1070,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_mknod(pathname, mode, dev); dfd = owner_walk_parent(pathname, &bname); if (dfd < 0) @@ -1172,13 +1107,13 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_mknod(pathname, mode, dev); if (!pathname || !*pathname || *pathname == '/') return do_mknod(pathname, mode, dev); - /* A path with a slash needs secure_relative_open to confine its + /* A path with a slash needs vfs_resolve_open to confine its * parent resolution; a top-level path lives in CWD (AT_FDCWD) with * no parent to subvert. The final component is protected below * regardless. */ @@ -1192,7 +1127,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) memcpy(dirpath, pathname, dlen); dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; owns = True; @@ -1269,7 +1204,7 @@ int do_rmdir(const char *pathname) int do_rmdir_at(const char *pathname) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1282,7 +1217,7 @@ int do_rmdir_at(const char *pathname) #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_rmdir(pathname); dfd = owner_walk_parent(pathname, &bname); if (dfd < 0) @@ -1295,7 +1230,7 @@ int do_rmdir_at(const char *pathname) } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return rmdir(pathname); if (!pathname || !*pathname || *pathname == '/') @@ -1314,7 +1249,7 @@ int do_rmdir_at(const char *pathname) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -1356,7 +1291,7 @@ int do_open(const char *pathname, int flags, mode_t mode) protections is later removed or regresses, the open here still refuses to escape the module. - Defence: open the parent of pathname under secure_relative_open() + Defence: open the parent of pathname under vfs_resolve_open() and call openat() against the resulting dirfd with O_NOFOLLOW (so the basename itself isn't followed if it happens to be a pre-planted symlink, which is what we want for O_CREAT|O_EXCL). @@ -1364,7 +1299,7 @@ int do_open(const char *pathname, int flags, mode_t mode) int do_open_at(const char *pathname, int flags, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1378,7 +1313,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode) #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_open(pathname, flags, mode); dfd = owner_walk_parent(pathname, &bname); if (dfd < 0) @@ -1391,7 +1326,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode) } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_open(pathname, flags, mode); if (!pathname || !*pathname || *pathname == '/') @@ -1410,7 +1345,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -1617,7 +1552,7 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) Because chmod() resolves symlinks at every component, the swap redirects the chmod outside the receiver's confinement. - Defence: open the *parent* directory of fname under secure_relative_open() + Defence: open the *parent* directory of fname under vfs_resolve_open() (a portable per-component O_NOFOLLOW walk on held parent dirfds) and do fchmodat() against that dirfd. A symlink substituted into one of the parent components is then either followed within the tree (legitimate dir-symlinks @@ -1637,7 +1572,7 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) int do_chmod_at(const char *fname, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1673,7 +1608,7 @@ int do_chmod_at(const char *fname, mode_t mode) * symlink they planted can only redirect to files they could * already access. Everywhere else, fall through to plain * do_chmod() to avoid the dirfd-open overhead on every call. */ - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_chmod(fname, mode); if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) @@ -1692,7 +1627,7 @@ int do_chmod_at(const char *fname, mode_t mode) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -1722,7 +1657,7 @@ int do_rename(const char *old_path, const char *new_path) source or the destination has an attacker-substituted symlink in one of its parent components, the rename can publish or vanish files outside the module. Defence: open the parent of *each* path under - secure_relative_open() and use renameat() against the resulting + vfs_resolve_open() and use renameat() against the resulting dirfds. When old_path and new_path share the same parent (the common case -- tmp file living next to its final name), we reuse the same dirfd for both sides. @@ -1733,7 +1668,7 @@ int do_rename(const char *old_path, const char *new_path) int do_rename_at(const char *old_path, const char *new_path) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; const char *old_slash, *new_slash; @@ -1745,7 +1680,7 @@ int do_rename_at(const char *old_path, const char *new_path) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_rename(old_path, new_path); if (!old_path || !*old_path || !new_path || !*new_path) @@ -1756,7 +1691,7 @@ int do_rename_at(const char *old_path, const char *new_path) * source): resolve each side's parent via the ownership walk (follow * uid0/euid symlinks, refuse others; absolute and relative alike). */ if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return do_rename(old_path, new_path); old_dfd = owner_walk_parent(old_path, &old_bname); if (old_dfd < 0) @@ -1809,7 +1744,7 @@ int do_rename_at(const char *old_path, const char *new_path) memcpy(old_dirpath, old_path, old_dlen); old_dirpath[old_dlen] = '\0'; old_bname = old_slash + 1; - old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); + old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); if (old_dfd < 0) return -1; old_owns = True; @@ -1848,7 +1783,7 @@ int do_rename_at(const char *old_path, const char *new_path) && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { new_dfd = old_dfd; } else { - new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); + new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); if (new_dfd < 0) { e = errno; if (old_owns) close(old_dfd); @@ -1923,7 +1858,7 @@ int do_mkdir(char *path, mode_t mode) mkdir() resolves parent symlinks at every component, so a parent- component swap can place an attacker-named directory outside the - module. Defence: open the parent of fname under secure_relative_open() + module. Defence: open the parent of fname under vfs_resolve_open() and call mkdirat() against that dirfd. Mutates path in place to trim trailing slashes (matches do_mkdir()). @@ -1933,7 +1868,7 @@ int do_mkdir(char *path, mode_t mode) int do_mkdir_at(char *path, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1947,7 +1882,7 @@ int do_mkdir_at(char *path, mode_t mode) #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return mkdir(path, mode); dfd = owner_walk_parent(path, &bname); if (dfd < 0) @@ -1960,7 +1895,7 @@ int do_mkdir_at(char *path, mode_t mode) } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return mkdir(path, mode); if (!path || !*path || *path == '/') @@ -1979,7 +1914,7 @@ int do_mkdir_at(char *path, mode_t mode) dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -2057,14 +1992,14 @@ int do_lstat(const char *path, STRUCT_STAT *st) "this isn't a directory, delete it" -> attacker-controlled unlink on something outside the module). - Defence: open the parent under secure_relative_open() and use + Defence: open the parent under vfs_resolve_open() and use fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against that dirfd. Same fall-through gating as the other wrappers. */ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -2073,7 +2008,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa #if defined O_NOFOLLOW && defined O_DIRECTORY if (operator_path_resolve) { - if (symlink_optout_allowed()) + if (vfs_symlink_optout_allowed()) return fallback(path, st); dfd = owner_walk_parent(path, &bname); if (dfd < 0) @@ -2086,7 +2021,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa } #endif - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return fallback(path, st); if (!path || !*path || *path == '/') @@ -2105,7 +2040,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa dirpath[dlen] = '\0'; bname = slash + 1; - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -2115,7 +2050,6 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa errno = e; return ret; #else - (void)at_flags; return fallback(path, st); #endif } @@ -2127,10 +2061,8 @@ int do_stat_at(const char *path, STRUCT_STAT *st) int do_lstat_at(const char *path, STRUCT_STAT *st) { -#if defined SUPPORT_LINKS && defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW +#ifdef SUPPORT_LINKS return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, do_lstat); -#elif defined SUPPORT_LINKS - return do_lstat(path, st); #else return do_xstat_at(path, st, 0, do_stat); #endif @@ -2165,13 +2097,13 @@ int do_setattrlist_times(const char *path, STRUCT_STAT *stp) /* setattrlist() takes a raw path and follows parent symlinks * (FSOPT_NOFOLLOW only blocks the final component). When hardened - * resolution is active -- secure_relpath_active(): any non-chroot + * resolution is active -- vfs_relpath_active(): any non-chroot * daemon/receiver module, plus a /./ inner-module chroot -- return * ENOSYS so set_times()' tier walk falls through to do_utimensat_at(), * which routes the update through a secure parent dirfd. The attribute * set this would have used (ATTR_CMN_MODTIME / ATTR_CMN_ACCTIME) is the * same set utimensat() handles, so no functionality is lost. */ - if (secure_relpath_active()) { + if (vfs_relpath_active()) { errno = ENOSYS; return -1; } @@ -2303,7 +2235,7 @@ int do_utimensat(const char *path, STRUCT_STAT *stp) lutimes() doesn't follow the final component but still resolves parents. Either way, a parent-symlink swap can redirect the timestamp update outside the module. Defence: open the parent of - path under secure_relative_open() and call utimensat() with + path under vfs_resolve_open() and call utimensat() with AT_SYMLINK_NOFOLLOW against that dirfd. Falls through to do_utimensat() in the same dry-run / non-daemon / @@ -2314,7 +2246,7 @@ int do_utimensat(const char *path, STRUCT_STAT *stp) int do_utimensat_at(const char *path, STRUCT_STAT *stp) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; struct timespec t[2]; char dirpath[MAXPATHLEN]; const char *bname; @@ -2325,7 +2257,7 @@ int do_utimensat_at(const char *path, STRUCT_STAT *stp) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - if (!secure_relpath_active()) + if (!vfs_relpath_active()) return do_utimensat(path, stp); if (!path || !*path || *path == '/') @@ -2357,7 +2289,7 @@ int do_utimensat_at(const char *path, STRUCT_STAT *stp) t[1].tv_nsec = 0; #endif - dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; @@ -2587,34 +2519,6 @@ int do_open_nofollow(const char *pathname, int flags) return fd; } -/* - open a file relative to a base directory. The basedir can be NULL, - in which case the current working directory is used. The relpath - must be a relative path. Resolution cannot escape basedir (or the - cwd, when basedir is NULL): no ".." jumps above the start, no - symlinks pointing outside, no absolute paths. - - Symlinks *within* basedir are followed normally — earlier rsync - versions rejected every symlink with O_NOFOLLOW on each component, - which broke legitimate directory symlinks on the receiver side - (https://github.com/RsyncProject/rsync/issues/715). - - Escape prevention is handled by a single portable mechanism on every - platform: a per-component O_NOFOLLOW walk on a stack of held parent - dirfds (the dirstack helpers above). Each component is opened - relative to a pinned parent fd, so no rename or symlink-swap can - redirect resolution; ".." pops to the already-held parent (never - above the anchor); an in-tree directory symlink is followed by - reading its target and walking it on the same stack (absolute - targets refused, symlink hops bounded). Single-component + O_NOFOLLOW - + a pinned parent fd is race-free by construction, with no kernel - "beneath" primitive required (see dev-notes/resolver-race-freeness). - - The relpath must also not contain any ../ elements in the path - (except for a deliberately re-anchored module path; see below). -*/ - - /* The logical current directory (maintained by change_dir() in util1.c). * Defined here -- rather than in util1.c -- so the test helpers that link * syscall.o but not util1.o (tls, trimslash) get the definition without a @@ -2622,324 +2526,6 @@ int do_open_nofollow(const char *pathname, int flags) char curr_dir[MAXPATHLEN]; unsigned int curr_dir_len; -#if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) - -/* Walk `relpath` confined beneath the borrowed anchor dirfd (which may be - * AT_FDCWD) and return the opened leaf fd, or -1. Does NOT close `anchor_fd` -- - * the caller owns it. Shared by secure_relative_open() (which first resolves a - * basedir to the anchor) and secure_relative_open_at() (handed an already-open - * anchor, e.g. a held module-root fd). `hops` is the shared symlink-hop budget. */ -static int secure_walk_at(int anchor_fd, const char *anchor_abspath, - const char *relpath, int flags, mode_t mode, int *hops) -{ - struct dirstack ds; - int retfd = -1; - char *path_copy; - - if (ds_init(&ds, anchor_fd) < 0) - return -1; - /* Seed the abspath tracker so the exclude-aware refusal can map a resolved - * path back to module-relative. Only an absolute anchor enables it. */ - if (anchor_abspath && anchor_abspath[0] == '/') - strlcpy(ds.abspath, anchor_abspath, sizeof ds.abspath); - path_copy = my_strdup(relpath, __FILE__, __LINE__); - if (!path_copy) { - ds_free(&ds); - return -1; - } - - /* Trim trailing slashes so the last-component test below is exact, then - * note the offset of the final component. */ - size_t pclen = strlen(path_copy); - while (pclen > 1 && path_copy[pclen-1] == '/') - path_copy[--pclen] = '\0'; - char *last_slash = strrchr(path_copy, '/'); - size_t last_off = last_slash ? (size_t)(last_slash + 1 - path_copy) : 0; - - int saw_component = 0; - char *psave = NULL; - for (char *part = strtok_r(path_copy, "/", &psave); - part != NULL; - part = strtok_r(NULL, "/", &psave)) - { - int is_last = (size_t)(part - path_copy) == last_off; - saw_component = 1; - - /* A literal "." or ".." is a movement, not a name to open. It must go - * through ds_descend(), which refuses to pop above the anchor, BEFORE - * the leaf fast paths below -- those openat() the component directly, - * so a final ".." would otherwise hand back the anchor's own parent - * (with O_NOFOLLOW) or open it transiently (without O_DIRECTORY). */ - if (part[0] == '.' - && (part[1] == '\0' || (part[1] == '.' && part[2] == '\0'))) { - if (ds_descend(&ds, part, hops) < 0) - goto cleanup; - if (is_last) { - if (flags & O_DIRECTORY) - retfd = ds_take(&ds); - else - errno = EISDIR; - goto cleanup; - } - continue; - } - - /* File leaf (final component, caller did not ask for O_DIRECTORY): - * never follow a symlink leaf. */ - if (is_last && !(flags & O_DIRECTORY)) { - if (ds.abspath[0]) { - char leafabs[MAXPATHLEN]; - if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) - < (int)sizeof leafabs - && abspath_outside_confinement(leafabs)) { - errno = ELOOP; - goto cleanup; - } - } - int next_fd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); - if (next_fd == -1 && (errno == ENOTDIR || errno == ENOENT)) { - retfd = openat(ds_cur(&ds), part, flags | O_NOFOLLOW, mode); - goto cleanup; - } - if (next_fd == -1) - goto cleanup; - close(next_fd); - errno = EISDIR; - goto cleanup; - } - - /* O_DIRECTORY|O_NOFOLLOW leaf: the caller's O_NOFOLLOW governs the leaf. */ - if (is_last && (flags & O_NOFOLLOW)) { - retfd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); - goto cleanup; - } - - /* Directory component (intermediate, or an O_DIRECTORY leaf to follow): - * descend on the stack, following in-tree symlinks. */ - if (ds_descend(&ds, part, hops) < 0) { - if (!is_last && errno == ENOTDIR) - errno = ELOOP; - goto cleanup; - } - if (is_last) { - retfd = ds_take(&ds); - goto cleanup; - } - } - - /* Empty relpath: hand back a real anchor for an O_DIRECTORY caller (ds_take - * dups the borrowed anchor), else EISDIR. An AT_FDCWD anchor is not a - * resolvable target, so it fails rather than silently returning the cwd. */ - if (!saw_component) { - if ((flags & O_DIRECTORY) && anchor_fd != AT_FDCWD) - retfd = ds_take(&ds); - else - errno = EISDIR; - } - -cleanup: - free(path_copy); - ds_free(&ds); - return retfd; -} -#endif /* O_NOFOLLOW && O_DIRECTORY && AT_FDCWD */ - -int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) -{ - extern int am_daemon, am_chrooted; - extern char *module_dir; - extern unsigned int module_dirlen; - char modrel_buf[MAXPATHLEN]; - int reanchored = 0; - - if (!relpath || relpath[0] == '/') { - // must be a relative path - errno = EINVAL; - return -1; - } - - /* Sanitizing daemon (am_daemon && !am_chrooted) and the /./ inner-module - * chroot (am_daemon && am_chrooted && module_dirlen) -- both keep the module - * root, not the cwd, as the trust boundary. Here we have chdir'd into a - * sub-dir of the module (the transfer destination), so a relative alt-dest - * like "../01" may legitimately climb to a sibling that is still inside the - * module (#915). Confining beneath the cwd would reject that climb. - * Re-anchor at the module root by prefixing the cwd's module-relative path - * (from rsync's logical curr_dir[], a guaranteed lexical prefix of - * module_dir, unlike getcwd()) and resolving beneath module_dir; RESOLVE_ - * BENEATH then allows in-module climbs and still rejects escapes. Only for - * paths that contain "..". module_dirlen is 0 for a `path = /` module - * (clientserver.c), so the non-chroot arm gates on module_dir, not its - * length, to cover that case too -- the prefix check below treats - * module_dirlen 0 as "module root is /". */ - if (am_daemon && (!am_chrooted || module_dirlen) - && module_dir && module_dir[0] == '/' - && (basedir == NULL || basedir[0] != '/') - && (path_has_dotdot_component(relpath) - || (basedir && path_has_dotdot_component(basedir)))) { - const char *p; - int n; - if (curr_dir_len >= module_dirlen - && strncmp(curr_dir, module_dir, module_dirlen) == 0 - && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { - for (p = curr_dir + module_dirlen; *p == '/'; p++) {} - if (basedir) - n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", - p, *p ? "/" : "", basedir, relpath); - else - n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s", - p, *p ? "/" : "", relpath); - if (n < 0 || n >= (int)sizeof modrel_buf) { - errno = ENAMETOOLONG; - return -1; - } - basedir = module_dir; /* absolute, operator-trusted anchor */ - relpath = modrel_buf; - reanchored = 1; - } - /* else: cwd not under module root as expected -- fall through to the - * front-door rejection below (fail safe). */ - } - - /* Reject any path with a literal ".." component (bare "..", - * "../foo", "foo/..", "foo/../bar", "subdir/..") at the front door, - * with EINVAL, so callers can rely on the validation regardless of - * platform. Skipped for a re-anchored path: its ".." is deliberate, - * stays within the module, and is adjudicated safely by the walk - * below (ds_descend pops a "../" to the held parent, never above the - * anchor). */ - if (!reanchored) { - if (path_has_dotdot_component(relpath)) { - errno = EINVAL; - return -1; - } - if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { - errno = EINVAL; - return -1; - } - } - -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - -#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) - // really old system, all we can do is live with the risks - if (!basedir) { - return open(relpath, flags, mode); - } - char fullpath[MAXPATHLEN]; - pathjoin(fullpath, sizeof fullpath, basedir, relpath); - return open(fullpath, flags, mode); -#else - int dirfd = AT_FDCWD; /* anchor for the relpath walk (owned unless AT_FDCWD) */ - int hops = SECURE_OPEN_MAXSYMLINKS; /* shared symlink-hop budget */ - if (basedir != NULL) { - if (basedir[0] == '/') { - /* Absolute basedir: operator-trusted. Prefer the identity-pinned - * module-root fd when this is the served module, so a dropped- - * privilege daemon need not re-traverse the absolute path. */ - dirfd = open_anchor_dirfd(basedir); - if (dirfd == -1) - return -1; - } else { - /* Relative basedir: resolve it on a dirfd stack anchored at - * the CWD, following in-tree directory symlinks -- the - * portable RESOLVE_BENEATH equivalent. A symlink target's - * ".." may climb but not above the CWD anchor. */ - struct dirstack bds; - char *bcopy; - if (ds_init(&bds, AT_FDCWD) < 0) - return -1; - bcopy = my_strdup(basedir, __FILE__, __LINE__); - if (!bcopy) { - ds_free(&bds); - return -1; - } - if (ds_walk_path(&bds, bcopy, &hops) < 0) { - int e = errno; - free(bcopy); - ds_free(&bds); - errno = e; - return -1; - } - free(bcopy); - dirfd = ds_take(&bds); /* owned dirfd for the basedir */ - ds_free(&bds); - if (dirfd == -1) - return -1; - } - } - - /* Absolute path of the anchor, for the exclude-aware refusal: the cwd (== - * module root for a daemon) when AT_FDCWD, or an operator-trusted absolute - * basedir. A relative basedir's resolved abspath isn't tracked, so leave it - * unseeded (the refusal is then a no-op for that uncommon case). */ - const char *anchor_abspath = !basedir ? curr_dir - : (basedir[0] == '/' ? basedir : NULL); - int retfd = secure_walk_at(dirfd, anchor_abspath, relpath, flags, mode, &hops); - if (dirfd != AT_FDCWD) - close(dirfd); - return retfd; -#endif // O_NOFOLLOW, O_DIRECTORY -} - -/* Common fd-anchored resolver. A caller may explicitly allow literal ".." - * components when the fd itself is the confinement boundary: secure_walk_at() - * resolves each one by popping its held-dirfd stack and refuses a pop above the - * anchor. Other callers retain the front-door validation used by - * secure_relative_open(). */ -static int secure_relative_open_at_internal(int anchor_fd, const char *relpath, - int flags, mode_t mode, int allow_dotdot) -{ -#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) - (void)anchor_fd; (void)relpath; (void)flags; (void)mode; (void)allow_dotdot; - errno = ENOSYS; - return -1; -#else - int hops = SECURE_OPEN_MAXSYMLINKS; - if (!relpath || relpath[0] == '/') { - errno = EINVAL; - return -1; - } - if (!allow_dotdot && path_has_dotdot_component(relpath)) { - errno = EINVAL; - return -1; - } -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - /* The anchor fd's absolute path isn't known here (it may be a held module - * root or a climbed-to dir), so leave the abspath tracker unseeded; the - * exclude-aware refusal is a no-op for this entry point. */ - return secure_walk_at(anchor_fd, NULL, relpath, flags, mode, &hops); -#endif -} - -/* Like secure_relative_open() but anchored at an already-open directory fd - * (borrowed -- the caller keeps ownership) rather than a basedir path. Lets a - * caller pin the trust root once -- e.g. a daemon's module root opened while - * still privileged -- and resolve a relative path beneath it without re-walking - * the absolute path as a dropped-privilege uid. The ordinary entry point keeps - * rejecting literal ".." components as suspicious caller input. */ -int secure_relative_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode) -{ - return secure_relative_open_at_internal(anchor_fd, relpath, flags, mode, 0); -} - -/* Resolve a path that may contain literal ".." beneath a trusted anchor fd. - * Used for a followed symlink target, where parent-relative components are - * normal pathname semantics. The held-fd stack still refuses every escape - * above anchor_fd. */ -int secure_relative_open_at_beneath(int anchor_fd, const char *relpath, - int flags, mode_t mode) -{ - return secure_relative_open_at_internal(anchor_fd, relpath, flags, mode, 1); -} - -#if defined O_NOFOLLOW && defined O_DIRECTORY && defined AT_FDCWD /* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic * quality; falls back to rand() if /dev/urandom cannot be opened or read * (e.g. inside a chroot or container without /dev populated). */ @@ -2960,7 +2546,6 @@ static void rand_bytes(unsigned char *buf, size_t len) buf[i] = (unsigned char)rand(); } } -#endif /* Create a unique temp file directly in directory `dfd` for the held-dirfd * traversal: `filename` is the basename ending in "XXXXXX", rewritten in place @@ -3021,7 +2606,7 @@ int do_mkstemp_atfd(int dfd, char *filename, mode_t perms) /* Secure version of mkstemp that prevents symlink attacks on parent directories. - Like secure_relative_open(), this walks the path checking each component + Like vfs_resolve_open(), this walks the path checking each component with O_NOFOLLOW to prevent TOCTOU race conditions. The template may be relative or absolute, but must not contain ../ components. @@ -3048,7 +2633,7 @@ int secure_mkstemp(char *template, mode_t perms, int operator_path) /* An operator-supplied --temp-dir may point outside the tree; --insecure-links * (or a daemon module's "insecure links =") restores legacy following. */ - if (operator_path && symlink_optout_allowed()) + if (operator_path && vfs_symlink_optout_allowed()) return do_mkstemp(template, perms); /* Open the temp file's directory. For an operator --temp-dir use the @@ -3076,7 +2661,7 @@ int secure_mkstemp(char *template, mode_t perms, int operator_path) } dirfd = operator_path ? open_no_attacker_symlinks(dir, O_RDONLY | O_DIRECTORY, 0) - : secure_relative_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); + : vfs_resolve_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); if (dirfd < 0) return -1; } @@ -3115,7 +2700,7 @@ int do_open_checklinks(const char *pathname) /* Held-directory-fd traversal. * * Rather than re-resolve a full path on every syscall (do_*_at() re-opens the - * parent via secure_relative_open() each call), the generator and receiver + * parent via vfs_resolve_open() each call), the generator and receiver * open each directory ONCE via open_dir_secure() and issue single-component * *at() ops against that held dirfd with the do_*_atfd() wrappers below. The * parent is a pinned fd, not re-resolved, so the per-entry symlink-race window @@ -3132,13 +2717,13 @@ int do_open_checklinks(const char *pathname) int open_dir_secure(const char *dirname) { #ifdef AT_FDCWD - extern int am_daemon, am_chrooted; + extern int am_daemon; int dfd; /* Authority gate, identical to the do_*_at() wrappers. When hardened * resolution isn't in effect, return -1 with errno cleared so the caller * uses the full-path wrappers. */ - if (!secure_relpath_active()) { + if (!vfs_relpath_active()) { errno = 0; return -1; } @@ -3152,7 +2737,7 @@ int open_dir_secure(const char *dirname) errno = 0; return -1; } else { - dfd = secure_relative_open(NULL, dirname, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_resolve_open(NULL, dirname, O_RDONLY | O_DIRECTORY, 0); } if (dfd >= 0) { @@ -3185,7 +2770,7 @@ int open_dir_secure(const char *dirname) * pinned fds stay valid, and a raced/replaced ancestor resolves to the original * inode the fd holds -- the held-dirfd race-safety property, not a hazard). * Each component is resolved with ds_descend(), which follows in-tree directory - * symlinks exactly as secure_relative_open() does; only the resolved dir fd is + * symlinks exactly as vfs_resolve_open() does; only the resolved dir fd is * kept (intermediate symlink-target fds are closed -- sound, since an open * dirfd needs no live parent). */ #if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY @@ -3210,7 +2795,7 @@ void reset_dir_fd_cache(void) * trusted root), reusing the held ancestor stack. Returns a BORROWED dirfd * owned by the cache (do NOT close), or -1 (errno preserved for a real open * error, errno==0 for an uncacheable path -- "..", too deep/long, or a relative - * non-cwd anchor) so the caller can fall back to secure_relative_open(). */ + * non-cwd anchor) so the caller can fall back to vfs_resolve_open(). */ static int dpc_dir_fd(const char *anchor, const char *dirpath) { char copy[MAXPATHLEN]; @@ -3285,7 +2870,7 @@ static int dpc_dir_fd(const char *anchor, const char *dirpath) return nc > 0 ? dpc_fd[dpc_depth-1] : dpc_base; } -/* Public entry for the sender (no secure_relpath_active gate: its send paths +/* Public entry for the sender (no vfs_relpath_active gate: its send paths * confine unconditionally). Borrowed fd; -1 => caller uses the full walk. */ int held_dir_path_fd(const char *anchor, const char *dirpath) { @@ -3294,7 +2879,7 @@ int held_dir_path_fd(const char *anchor, const char *dirpath) int get_dir_fd(const char *dirname) { - if (!secure_relpath_active()) { errno = 0; return -1; } + if (!vfs_relpath_active()) { errno = 0; return -1; } return dpc_dir_fd(NULL, dirname); } #else diff --git a/t_rename_secure.c b/t_rename_secure.c index 79684a004..9de9f761f 100644 --- a/t_rename_secure.c +++ b/t_rename_secure.c @@ -1,6 +1,6 @@ /* * Test harness for do_rename_at(): a mixed top-level/slashed rename must still - * resolve the slashed side's parent under secure_relative_open() rather than + * resolve the slashed side's parent under vfs_resolve_open() rather than * fall back to plain rename(). Not linked into rsync. GPL version 2. */ @@ -23,7 +23,7 @@ static int errs = 0; #ifdef AT_FDCWD /* The 3.4.3 bug: if either side has no slash the whole op fell back to plain - * rename(), leaving the slashed side's parent outside secure_relative_open(). */ + * rename(), leaving the slashed side's parent outside vfs_resolve_open(). */ static int vulnerable_mixed_rename_at(const char *old_path, const char *new_path) { const char *old_slash, *new_slash; diff --git a/t_secure_relpath.c b/t_secure_relpath.c index d20570d61..0bef02114 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -1,5 +1,5 @@ /* - * Test harness for secure_relative_open()'s front-door input + * Test harness for vfs_resolve_open()'s front-door input * validation. Codex audit Finding 5 noted that the existing check * * if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) @@ -14,7 +14,7 @@ * pre-5.6 Linux does not, so the validation must happen at the * front door. * - * This helper invokes secure_relative_open() with each suspect + * This helper invokes vfs_resolve_open() with each suspect * input and checks both the failure (rc < 0) and the errno * (EINVAL means "rejected at the front door"). Pre-fix, the kernel * may reject with a different errno (EXDEV from RESOLVE_BENEATH); @@ -47,7 +47,7 @@ static void check_relpath(const char *relpath) int saved_errno; errno = 0; - fd = secure_relative_open(NULL, relpath, O_RDONLY | O_DIRECTORY, 0); + fd = vfs_resolve_open(NULL, relpath, O_RDONLY | O_DIRECTORY, 0); saved_errno = errno; if (fd >= 0) { @@ -76,7 +76,7 @@ static void check_basedir(const char *basedir) int saved_errno; errno = 0; - fd = secure_relative_open(basedir, "ok", O_RDONLY | O_DIRECTORY, 0); + fd = vfs_resolve_open(basedir, "ok", O_RDONLY | O_DIRECTORY, 0); saved_errno = errno; if (fd >= 0) { @@ -184,7 +184,7 @@ int main(int argc, char **argv) return 2; } - /* secure_relative_open's daemon-only confinement protections only + /* vfs_resolve_open's daemon-only confinement protections only * fire when am_daemon && !am_chrooted (the threat model is the * daemon-no-chroot deployment), but the front-door input * validation runs unconditionally. We set am_daemon anyway so the @@ -196,7 +196,7 @@ int main(int argc, char **argv) symlink("subdir", "alias"); /* Each of these relpaths must be rejected with EINVAL at the - * secure_relative_open() front door. ".." is the actual one-level + * vfs_resolve_open() front door. ".." is the actual one-level * escape; the others ("subdir/..", "subdir/../subdir") resolve * back to the start dir on systems that allow them, but we still * reject them as defence-in-depth: a path containing a ".." token diff --git a/t_stub.c b/t_stub.c index 1518c7932..912ec0333 100644 --- a/t_stub.c +++ b/t_stub.c @@ -42,14 +42,12 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; * 0 here makes every my_alloc()/my_strdup() in * util2.c trip the "exceeded --max-alloc=0" * check, which any helper exercising the - * per-component fallback of secure_relative_open() + * per-component fallback of vfs_resolve_open() * hits at its first my_strdup() call. */ char *partial_dir; char *module_dir; int module_dirfd = -1; -char *confine_root; -unsigned int confine_rootlen = 0; -/* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in +/* curr_dir[]/curr_dir_len (read by vfs_resolve_open) are defined in * syscall.c, which every helper links -- no stub needed here. */ filter_rule_list daemon_filter_list; diff --git a/util1.c b/util1.c index 87ff22595..ff119a97e 100644 --- a/util1.c +++ b/util1.c @@ -393,38 +393,14 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) OFF_T prealloc_len = 0, offset = 0; /* For any hardened (non-chrooted) receiver, route the source open through - * secure_relative_open so a parent-symlink on the source path (e.g. + * vfs_resolve_open so a parent-symlink on the source path (e.g. * --copy-dest=cd where cd is a symlink to an outside directory) cannot * redirect the read to a file the attacker should not see. Plain * do_open_nofollow only refuses a final-component symlink; parents are - * still followed. An ABSOLUTE source is an operator basis (e.g. an absolute - * --copy-dest): confine its parents via the ownership walk -- a foreign-owned - * parent symlink is refused, the operator's own dirs/uid0/euid symlinks - * followed -- so a flipped parent can't redirect the basis read out of tree. - * operator_path_resolve is set only across the walk (so module-exclude is - * enforced) and restored, leaving the caller's value for the dest side -- this - * is why confining the source here does not re-open the copy_xattrs dest - * race the way wrapping the whole copy_altdest_file would. */ - if (secure_relpath_active() && source && *source && source[0] != '/') - ifd = secure_relative_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY - else if (secure_relpath_active() && source && source[0] == '/' - && !symlink_optout_allowed()) { - int save = operator_path_resolve, dfd, e; - const char *leaf; - operator_path_resolve = 1; - dfd = owner_walk_parent(source, &leaf); - operator_path_resolve = save; - if (dfd < 0) - ifd = -1; - else { - ifd = openat(dfd, leaf, O_RDONLY | O_NOFOLLOW); - e = errno; - close(dfd); - errno = e; - } - } -#endif + * still followed. (An absolute source is operator-trusted -- e.g. an + * absolutized basis dir -- and uses do_open_nofollow.) */ + if (vfs_relpath_active() && source && *source && source[0] != '/') + ifd = vfs_resolve_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); else ifd = do_open_nofollow(source, O_RDONLY); if (ifd < 0) { @@ -1317,7 +1293,7 @@ int change_dir(const char *dir, int set_path_only) * target -- otherwise CWD escapes the module and * every subsequent path-relative syscall (open, * chmod, lchown, ...) inherits the escape, which - * defeats secure_relative_open's RESOLVE_BENEATH + * defeats vfs_resolve_open's RESOLVE_BENEATH * anchor and re-opens the CVE-2026-29518 class of * symlink TOCTOU attacks. Use the secure resolver * to get a confined dirfd, then fchdir() to it. @@ -1344,7 +1320,7 @@ int change_dir(const char *dir, int set_path_only) prefix[save_dir_len] = '\0'; basedir = prefix; } - dfd = secure_relative_open(basedir, dir, + dfd = vfs_resolve_open(basedir, dir, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) { chdir_failed = 1; diff --git a/vfs/dirstack.c b/vfs/dirstack.c index ca4aa74f0..5ecbfcc88 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -20,7 +20,7 @@ #include "vfs/vfs_internal.h" /* Returns 1 if path has any "/"-separated component that is exactly - * "..", 0 otherwise. Used by secure_relative_open's front-door + * "..", 0 otherwise. Used by vfs_resolve_open's front-door * validation to reject ".." inputs (bare "..", "foo/..", "subdir/..") * for non-re-anchored paths; the walk itself resolves an in-tree ".." * safely (ds_descend pops to the held parent) for a re-anchored path. */ diff --git a/vfs/secure_open.c b/vfs/secure_open.c new file mode 100644 index 000000000..8e4117695 --- /dev/null +++ b/vfs/secure_open.c @@ -0,0 +1,362 @@ +/* + * vfs/secure_open.c - rsync's race-safe path resolver and its policy gates. + * + * vfs_resolve_open()/vfs_resolve_open_at() walk a relative path one component + * at a time beneath a trusted anchor (via the dirstack in vfs/dirstack.c), so a + * parent-component symlink swapped mid-walk cannot redirect resolution. The two + * gates decide when this hardening applies and whether the symlink confinement + * is opted out. Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +extern int am_chrooted; +extern int am_sender; +extern int module_id; +extern int insecure_links; +extern int open_noatime; +extern char curr_dir[MAXPATHLEN]; +extern unsigned int curr_dir_len; + +/* Single gate for whether path resolution must be hardened against + * parent-component symlink races (TOCTOU). Used by the do_*_at()/do_*_atfd() + * wrappers and the receiver's secure-open/secure-mkstemp choices. Hardens + * every non-chrooted receiver (a chroot is its own confinement); the sender is + * excluded so it still follows -L/--copy-links symlinks. A daemon chroot with + * an inner-module /./ boundary still needs these checks because the kernel + * chroot confines the outer path, not the inner module. */ +int vfs_relpath_active(void) +{ + if (am_daemon && am_chrooted && module_dirlen) + return 1; + return !am_chrooted && (am_daemon || !am_sender); +} + +/* Whether the operator-supplied-path symlink confinement is opted out. For a + * non-daemon transfer this is the local --insecure-links flag. For a daemon it + * is governed ONLY by the module's "insecure links" config (lp_insecure_links) + * -- never by a peer-supplied --insecure-links (a client cannot disable a + * daemon's confinement; the daemon also drops a connection that sends it). So a + * forwarded flag is structurally inert here. */ +int vfs_symlink_optout_allowed(void) +{ + if (am_daemon) + return module_id >= 0 && lp_insecure_links(module_id); + return insecure_links; +} + +/* + open a file relative to a base directory. The basedir can be NULL, + in which case the current working directory is used. The relpath + must be a relative path. Resolution cannot escape basedir (or the + cwd, when basedir is NULL): no ".." jumps above the start, no + symlinks pointing outside, no absolute paths. + + Symlinks *within* basedir are followed normally — earlier rsync + versions rejected every symlink with O_NOFOLLOW on each component, + which broke legitimate directory symlinks on the receiver side + (https://github.com/RsyncProject/rsync/issues/715). + + Escape prevention is handled by a single portable mechanism on every + platform: a per-component O_NOFOLLOW walk on a stack of held parent + dirfds (the dirstack helpers above). Each component is opened + relative to a pinned parent fd, so no rename or symlink-swap can + redirect resolution; ".." pops to the already-held parent (never + above the anchor); an in-tree directory symlink is followed by + reading its target and walking it on the same stack (absolute + targets refused, symlink hops bounded). Single-component + O_NOFOLLOW + + a pinned parent fd is race-free by construction, with no kernel + "beneath" primitive required (see dev-notes/resolver-race-freeness). + + The relpath must also not contain any ../ elements in the path + (except for a deliberately re-anchored module path; see below). +*/ + +#if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) + +/* Walk `relpath` confined beneath the borrowed anchor dirfd (which may be + * AT_FDCWD) and return the opened leaf fd, or -1. Does NOT close `anchor_fd` -- + * the caller owns it. Shared by vfs_resolve_open() (which first resolves a + * basedir to the anchor) and vfs_resolve_open_at() (handed an already-open + * anchor, e.g. a held module-root fd). `hops` is the shared symlink-hop budget. */ +static int secure_walk_at(int anchor_fd, const char *anchor_abspath, + const char *relpath, int flags, mode_t mode, int *hops) +{ + struct dirstack ds; + int retfd = -1; + char *path_copy; + + if (ds_init(&ds, anchor_fd) < 0) + return -1; + /* Seed the abspath tracker so the exclude-aware refusal can map a resolved + * path back to module-relative. Only an absolute anchor enables it. */ + if (anchor_abspath && anchor_abspath[0] == '/') + strlcpy(ds.abspath, anchor_abspath, sizeof ds.abspath); + path_copy = my_strdup(relpath, __FILE__, __LINE__); + if (!path_copy) { + ds_free(&ds); + return -1; + } + + /* Trim trailing slashes so the last-component test below is exact, then + * note the offset of the final component. */ + size_t pclen = strlen(path_copy); + while (pclen > 1 && path_copy[pclen-1] == '/') + path_copy[--pclen] = '\0'; + char *last_slash = strrchr(path_copy, '/'); + size_t last_off = last_slash ? (size_t)(last_slash + 1 - path_copy) : 0; + + int saw_component = 0; + char *psave = NULL; + for (char *part = strtok_r(path_copy, "/", &psave); + part != NULL; + part = strtok_r(NULL, "/", &psave)) + { + int is_last = (size_t)(part - path_copy) == last_off; + saw_component = 1; + + /* File leaf (final component, caller did not ask for O_DIRECTORY): + * never follow a symlink leaf. */ + if (is_last && !(flags & O_DIRECTORY)) { + if (ds.abspath[0]) { + char leafabs[MAXPATHLEN]; + if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) + < (int)sizeof leafabs + && abspath_excluded_by_module(leafabs, 0)) { + errno = ELOOP; + goto cleanup; + } + } + int next_fd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (next_fd == -1 && (errno == ENOTDIR || errno == ENOENT)) { + retfd = openat(ds_cur(&ds), part, flags | O_NOFOLLOW, mode); + goto cleanup; + } + if (next_fd == -1) + goto cleanup; + close(next_fd); + errno = EISDIR; + goto cleanup; + } + + /* O_DIRECTORY|O_NOFOLLOW leaf: the caller's O_NOFOLLOW governs the leaf. */ + if (is_last && (flags & O_NOFOLLOW)) { + retfd = openat(ds_cur(&ds), part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + goto cleanup; + } + + /* Directory component (intermediate, or an O_DIRECTORY leaf to follow): + * descend on the stack, following in-tree symlinks. */ + if (ds_descend(&ds, part, hops) < 0) { + if (!is_last && errno == ENOTDIR) + errno = ELOOP; + goto cleanup; + } + if (is_last) { + retfd = ds_take(&ds); + goto cleanup; + } + } + + /* Empty relpath: hand back a real anchor for an O_DIRECTORY caller (ds_take + * dups the borrowed anchor), else EISDIR. An AT_FDCWD anchor is not a + * resolvable target, so it fails rather than silently returning the cwd. */ + if (!saw_component) { + if ((flags & O_DIRECTORY) && anchor_fd != AT_FDCWD) + retfd = ds_take(&ds); + else + errno = EISDIR; + } + +cleanup: + free(path_copy); + ds_free(&ds); + return retfd; +} +#endif /* O_NOFOLLOW && O_DIRECTORY && AT_FDCWD */ + +int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode) +{ + extern int am_daemon, am_chrooted; + extern char *module_dir; + extern unsigned int module_dirlen; + char modrel_buf[MAXPATHLEN]; + int reanchored = 0; + + if (!relpath || relpath[0] == '/') { + // must be a relative path + errno = EINVAL; + return -1; + } + + /* Sanitizing daemon (am_daemon && !am_chrooted) and the /./ inner-module + * chroot (am_daemon && am_chrooted && module_dirlen) -- both keep the module + * root, not the cwd, as the trust boundary. Here we have chdir'd into a + * sub-dir of the module (the transfer destination), so a relative alt-dest + * like "../01" may legitimately climb to a sibling that is still inside the + * module (#915). Confining beneath the cwd would reject that climb. + * Re-anchor at the module root by prefixing the cwd's module-relative path + * (from rsync's logical curr_dir[], a guaranteed lexical prefix of + * module_dir, unlike getcwd()) and resolving beneath module_dir; RESOLVE_ + * BENEATH then allows in-module climbs and still rejects escapes. Only for + * paths that contain "..". module_dirlen is 0 for a `path = /` module + * (clientserver.c), so the non-chroot arm gates on module_dir, not its + * length, to cover that case too -- the prefix check below treats + * module_dirlen 0 as "module root is /". */ + if (am_daemon && (!am_chrooted || module_dirlen) + && module_dir && module_dir[0] == '/' + && (basedir == NULL || basedir[0] != '/') + && (path_has_dotdot_component(relpath) + || (basedir && path_has_dotdot_component(basedir)))) { + const char *p; + int n; + if (curr_dir_len >= module_dirlen + && strncmp(curr_dir, module_dir, module_dirlen) == 0 + && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { + for (p = curr_dir + module_dirlen; *p == '/'; p++) {} + if (basedir) + n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", + p, *p ? "/" : "", basedir, relpath); + else + n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s", + p, *p ? "/" : "", relpath); + if (n < 0 || n >= (int)sizeof modrel_buf) { + errno = ENAMETOOLONG; + return -1; + } + basedir = module_dir; /* absolute, operator-trusted anchor */ + relpath = modrel_buf; + reanchored = 1; + } + /* else: cwd not under module root as expected -- fall through to the + * front-door rejection below (fail safe). */ + } + + /* Reject any path with a literal ".." component (bare "..", + * "../foo", "foo/..", "foo/../bar", "subdir/..") at the front door, + * with EINVAL, so callers can rely on the validation regardless of + * platform. Skipped for a re-anchored path: its ".." is deliberate, + * stays within the module, and is adjudicated safely by the walk + * below (ds_descend pops a "../" to the held parent, never above the + * anchor). */ + if (!reanchored) { + if (path_has_dotdot_component(relpath)) { + errno = EINVAL; + return -1; + } + if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { + errno = EINVAL; + return -1; + } + } + +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + +#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + // really old system, all we can do is live with the risks + if (!basedir) { + return open(relpath, flags, mode); + } + char fullpath[MAXPATHLEN]; + pathjoin(fullpath, sizeof fullpath, basedir, relpath); + return open(fullpath, flags, mode); +#else + int dirfd = AT_FDCWD; /* anchor for the relpath walk (owned unless AT_FDCWD) */ + int hops = SECURE_OPEN_MAXSYMLINKS; /* shared symlink-hop budget */ + if (basedir != NULL) { + if (basedir[0] == '/') { + /* Absolute basedir: operator-trusted. Prefer the identity-pinned + * module-root fd when this is the served module, so a dropped- + * privilege daemon need not re-traverse the absolute path. */ + dirfd = open_anchor_dirfd(basedir); + if (dirfd == -1) + return -1; + } else { + /* Relative basedir: resolve it on a dirfd stack anchored at + * the CWD, following in-tree directory symlinks -- the + * portable RESOLVE_BENEATH equivalent. A symlink target's + * ".." may climb but not above the CWD anchor. */ + struct dirstack bds; + char *bcopy; + if (ds_init(&bds, AT_FDCWD) < 0) + return -1; + bcopy = my_strdup(basedir, __FILE__, __LINE__); + if (!bcopy) { + ds_free(&bds); + return -1; + } + if (ds_walk_path(&bds, bcopy, &hops) < 0) { + int e = errno; + free(bcopy); + ds_free(&bds); + errno = e; + return -1; + } + free(bcopy); + dirfd = ds_take(&bds); /* owned dirfd for the basedir */ + ds_free(&bds); + if (dirfd == -1) + return -1; + } + } + + /* Absolute path of the anchor, for the exclude-aware refusal: the cwd (== + * module root for a daemon) when AT_FDCWD, or an operator-trusted absolute + * basedir. A relative basedir's resolved abspath isn't tracked, so leave it + * unseeded (the refusal is then a no-op for that uncommon case). */ + const char *anchor_abspath = !basedir ? curr_dir + : (basedir[0] == '/' ? basedir : NULL); + int retfd = secure_walk_at(dirfd, anchor_abspath, relpath, flags, mode, &hops); + if (dirfd != AT_FDCWD) + close(dirfd); + return retfd; +#endif // O_NOFOLLOW, O_DIRECTORY +} + +/* Like vfs_resolve_open() but anchored at an already-open directory fd + * (borrowed -- the caller keeps ownership) rather than a basedir path. Lets a + * caller pin the trust root once -- e.g. a daemon's module root opened while + * still privileged, or reached by climbing ".." up from the cwd -- and resolve a + * relative path beneath it without re-walking (and re-permission-checking) the + * absolute path as a dropped-privilege uid. `relpath` must be relative and must + * not contain a literal ".." component (a ".." inside a followed in-tree symlink + * target is still handled by the walk). */ +int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode) +{ +#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + (void)anchor_fd; (void)relpath; (void)flags; (void)mode; + errno = ENOSYS; + return -1; +#else + int hops = SECURE_OPEN_MAXSYMLINKS; + if (!relpath || relpath[0] == '/') { + errno = EINVAL; + return -1; + } + if (path_has_dotdot_component(relpath)) { + errno = EINVAL; + return -1; + } +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + /* The anchor fd's absolute path isn't known here (it may be a held module + * root or a climbed-to dir), so leave the abspath tracker unseeded; the + * exclude-aware refusal is a no-op for this entry point. */ + return secure_walk_at(anchor_fd, NULL, relpath, flags, mode, &hops); +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 01eb87a4d..ee86c7f16 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -57,4 +57,10 @@ extern struct vfs vfs; * from the static initializer in vfs/vfs.c, not from this call. */ void vfs_init(void); +/* Race-safe path resolution (vfs/secure_open.c). */ +int vfs_relpath_active(void); +int vfs_symlink_optout_allowed(void); +int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode); +int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode); + #endif /* RSYNC_VFS_H */ From b5d30f926b42d5aa9585bc828d0db9c22ad776ef Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:03:20 +1000 Subject: [PATCH 04/69] vfs: move the operator-path ownership walk into vfs/owner_walk.c Relocate the operator-supplied-path resolver out of syscall.c into vfs/owner_walk.c with the vfs_* public names: open_no_attacker_symlinks -> vfs_open_owner_walk owner_walk_parent -> vfs_owner_walk_parent The static helpers ona_open and abspath_step move with them, as does the operator_path_resolve flag definition (commit-8 will fold it into the vfs struct). Function bodies are unchanged; the call sites across the daemon and option-parsing files are updated and the two entry points declared in vfs/vfs.h. With the owner-walk gone, syscall.c no longer references am_daemon, so the now-dead `extern int am_daemon` declarations (the do_*_at wrappers delegate to vfs_relpath_active) are dropped. No behavior change. --- Makefile.in | 2 +- authenticate.c | 4 +- batch.c | 8 +- clientserver.c | 4 +- connection.c | 2 +- exclude.c | 19 +-- generator.c | 2 +- log.c | 2 +- options.c | 13 +- params.c | 2 +- receiver.c | 2 +- syscall.c | 394 ++--------------------------------------------- util1.c | 8 +- vfs/owner_walk.c | 370 ++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 4 + 15 files changed, 405 insertions(+), 431 deletions(-) create mode 100644 vfs/owner_walk.c diff --git a/Makefile.in b/Makefile.in index 26516bc1e..59c96f3a0 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/authenticate.c b/authenticate.c index 3376bb1ed..ad11cba7a 100644 --- a/authenticate.c +++ b/authenticate.c @@ -156,7 +156,7 @@ static const char *check_secret(int module, const char *user, const char *group, if (!fname || !*fname) return "no secrets file"; { - int fd = open_no_attacker_symlinks(fname, O_RDONLY, 0); + int fd = vfs_open_owner_walk(fname, O_RDONLY, 0); if (fd < 0) return "no secrets file"; fh = fdopen(fd, "r"); @@ -242,7 +242,7 @@ static const char *getpassf(const char *filename) * (e.g. shadow hashes) to a malicious daemon; the do_stat() * other-access check runs on the target mode and passes 0640 * root:shadow. Refuse symlinks not owned by uid 0 or our euid. */ - if ((fd = open_no_attacker_symlinks(filename, O_RDONLY, 0)) < 0) { + if ((fd = vfs_open_owner_walk(filename, O_RDONLY, 0)) < 0) { rsyserr(FERROR, errno, "could not open password file %s", filename); exit_cleanup(RERR_SYNTAX); } diff --git a/batch.c b/batch.c index a4d19c058..883c86c22 100644 --- a/batch.c +++ b/batch.c @@ -251,7 +251,7 @@ void open_batch_files(void) stringjoin(filename, sizeof filename, batch_name, ".sh", NULL); - batch_sh_fd = open_no_attacker_symlinks(filename, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR | S_IXUSR); + batch_sh_fd = vfs_open_owner_walk(filename, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR | S_IXUSR); if (batch_sh_fd < 0) { rsyserr(FERROR, errno, "Batch file %s open error", full_fname(filename)); exit_cleanup(RERR_FILESELECT); @@ -259,12 +259,12 @@ void open_batch_files(void) /* O_BINARY: the batch stream is binary protocol data; without it * Cygwin et al apply CRLF translation and corrupt it. Unlike - * do_open(), open_no_attacker_symlinks passes flags verbatim. */ - batch_fd = open_no_attacker_symlinks(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR); + * do_open(), vfs_open_owner_walk passes flags verbatim. */ + batch_fd = vfs_open_owner_walk(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR); } else if (strcmp(batch_name, "-") == 0) batch_fd = STDIN_FILENO; else - batch_fd = open_no_attacker_symlinks(batch_name, O_RDONLY | O_BINARY, S_IRUSR | S_IWUSR); + batch_fd = vfs_open_owner_walk(batch_name, O_RDONLY | O_BINARY, S_IRUSR | S_IWUSR); if (batch_fd < 0) { rsyserr(FERROR, errno, "Batch file %s open error", full_fname(batch_name)); diff --git a/clientserver.c b/clientserver.c index cbbe62058..86d988e32 100644 --- a/clientserver.c +++ b/clientserver.c @@ -185,7 +185,7 @@ static int exchange_protocols(int f_in, int f_out, char *buf, size_t bufsiz, int /* 'motd file = PATH': motd content is sent to every client, so * a planted symlink would leak the target's bytes. Refuse * symlinks not owned by uid 0 or our euid. */ - int motd_fd = open_no_attacker_symlinks(motd, O_RDONLY, 0); + int motd_fd = vfs_open_owner_walk(motd, O_RDONLY, 0); FILE *f = motd_fd >= 0 ? fdopen(motd_fd, "r") : NULL; if (!f && motd_fd >= 0) close(motd_fd); while (f && !feof(f)) { @@ -300,7 +300,7 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char STRUCT_STAT st; /* --early-input-file=PATH: refuse symlinks not owned by uid 0 or * our euid anywhere in the path. */ - int ei_fd = open_no_attacker_symlinks(early_input_file, O_RDONLY, 0); + int ei_fd = vfs_open_owner_walk(early_input_file, O_RDONLY, 0); FILE *f = ei_fd >= 0 ? fdopen(ei_fd, "rb") : NULL; if (!f && ei_fd >= 0) close(ei_fd); if (!f || do_fstat(fileno(f), &st) < 0) { diff --git a/connection.c b/connection.c index a5ca19f3d..1d212628c 100644 --- a/connection.c +++ b/connection.c @@ -32,7 +32,7 @@ int claim_connection(char *fname, int max_connections) /* 'lock file = PATH': refuse symlinks not owned by uid 0 or our euid so * a planted parent can't redirect the root daemon's O_CREAT open. */ - if ((fd = open_no_attacker_symlinks(fname, O_RDWR|O_CREAT, 0600)) < 0) + if ((fd = vfs_open_owner_walk(fname, O_RDWR|O_CREAT, 0600)) < 0) return 0; /* Find a free spot. */ diff --git a/exclude.c b/exclude.c index 7476f6a87..cf6aaf09d 100644 --- a/exclude.c +++ b/exclude.c @@ -1665,24 +1665,7 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ open_path = line; } else open_path = fname; - - /* Confine the open to the module root. The ownership walk on its own - * is not enough for a peer-driven merge file: a non-chrooted daemon - * writes --backup-dir entries as root, so a raced backup symlink is - * ROOT-owned -- exactly what open_no_attacker_symlinks() treats as - * trusted -- and naming it in a dir-merge rule would read an - * out-of-module file in as filter rules (their text comes back to the - * peer in "Unknown filter rule" errors). - * - * The daemon's own "filter"/"include from"/"exclude from" parameters - * are exempt: those are operator-configured and legitimately live - * outside the module (/etc/rsync/excludes and the like). */ - int save_opr = operator_path_resolve; - if (!daemon_config_filter_file) - operator_path_resolve = 1; - fd = open_no_attacker_symlinks(open_path, O_RDONLY, 0); - operator_path_resolve = save_opr; - + fd = vfs_open_owner_walk(open_path, O_RDONLY, 0); if (fd < 0) fp = NULL; else if (!(fp = fdopen(fd, "rb"))) diff --git a/generator.c b/generator.c index 165cbe28c..beffa724f 100644 --- a/generator.c +++ b/generator.c @@ -978,7 +978,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) * the plain path (a lower-severity, non-root basis lookup). */ if (!am_daemon && am_root >= 0 && !vfs_symlink_optout_allowed()) { const char *leaf; - int dfd = owner_walk_parent(path, &leaf); + int dfd = vfs_owner_walk_parent(path, &leaf); int r, e; if (dfd < 0) return -1; diff --git a/log.c b/log.c index 889b57bba..b7086f041 100644 --- a/log.c +++ b/log.c @@ -166,7 +166,7 @@ static void logfile_open(void) * attacker-writable dirs; a planted symlink could redirect root's log * into e.g. /root/.ssh/authorized_keys. Refuse symlinks not owned by * uid 0 or our euid. */ - int fd = open_no_attacker_symlinks(logfile_name, + int fd = vfs_open_owner_walk(logfile_name, O_WRONLY | O_APPEND | O_CREAT, 0644); logfile_fp = fd >= 0 ? fdopen(fd, "a") : NULL; if (!logfile_fp && fd >= 0) diff --git a/options.c b/options.c index bcfa6d3ff..f4741173d 100644 --- a/options.c +++ b/options.c @@ -2642,17 +2642,8 @@ int parse_arguments(int *argc_p, const char ***argv_p) } /* Operator-supplied path that may transit attacker-writable * parents; refuse symlinks not owned by uid 0 or our euid, - * as for --exclude-from/--include-from/--filter in exclude.c. - * A daemon reads this list from a CLIENT-requested path - * (--files-from=:LIST) and it must stay inside the module: - * operator_path_resolve makes the ownership walk also refuse a - * (trusted-owned) symlink that redirects the list outside the - * module root -- e.g. a root-owned backup symlink. No-op off a - * daemon (the module-root check only fires when am_daemon). */ - int save_opr = operator_path_resolve; - operator_path_resolve = 1; - filesfrom_fd = open_no_attacker_symlinks(files_from, O_RDONLY|O_BINARY, 0); - operator_path_resolve = save_opr; + * as for --exclude-from/--include-from/--filter in exclude.c. */ + filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0); if (filesfrom_fd < 0) { snprintf(err_buf, sizeof err_buf, "failed to open files-from file %s: %s\n", diff --git a/params.c b/params.c index 933db7cb1..b31aec2b1 100644 --- a/params.c +++ b/params.c @@ -583,7 +583,7 @@ static FILE *OpenConfFile( char *FileName ) /* rsyncd.conf path (--config or default): a planted symlink could redirect * the daemon's config read. Refuse symlinks not owned by uid 0 or euid. */ { - int cfg_fd = open_no_attacker_symlinks( FileName, O_RDONLY, 0 ); + int cfg_fd = vfs_open_owner_walk( FileName, O_RDONLY, 0 ); OpenedFile = cfg_fd >= 0 ? fdopen( cfg_fd, "r" ) : NULL; if( !OpenedFile && cfg_fd >= 0 ) close( cfg_fd ); diff --git a/receiver.c b/receiver.c index b8022855c..5df9b75e5 100644 --- a/receiver.c +++ b/receiver.c @@ -135,7 +135,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags } p = fullpath; } - return open_no_attacker_symlinks(p, flags, mode); + return vfs_open_owner_walk(p, flags, mode); } /* The confined resolver is needed for the sanitizing daemon diff --git a/syscall.c b/syscall.c index 5cfbfd8e2..6e63cc9d3 100644 --- a/syscall.c +++ b/syscall.c @@ -53,373 +53,12 @@ extern int preserve_executability; extern int open_noatime; extern int copy_links; extern int copy_unsafe_links; -extern int am_daemon; extern int insecure_links; extern int module_id; -/* Advance the tracked absolute path `abspath` by one resolved component, - * normalizing "." and ".." exactly as openat() does so the module-confinement - * check (abspath_outside_confinement) sees the REAL resolved target. -1/ - * ENAMETOOLONG on overflow. */ -static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp_len) -{ - if (comp_len == 1 && comp[0] == '.') - return 0; /* "." -- no movement */ - if (comp_len == 2 && comp[0] == '.' && comp[1] == '.') { - char *s = strrchr(abspath, '/'); /* ".." -- pop a component */ - if (s) - *s = '\0'; - else - abspath[0] = '\0'; - return 0; - } - size_t al = strlen(abspath); - size_t off = (al > 0 && abspath[al-1] == '/') ? al : al + 1; /* no "//" */ - if (off + comp_len >= cap) { - errno = ENAMETOOLONG; - return -1; - } - if (off != al) - abspath[al] = '/'; - memcpy(abspath + off, comp, comp_len + 1); - return 0; -} - -/* Open an operator-supplied path, refusing to traverse any symlink (parent or - * leaf) not owned by uid 0 or our euid. A trusted-owned symlink (e.g. root's - * /var/log -> /data/log) is still followed; an untrusted one fails ELOOP. - * Unlike plain O_NOFOLLOW this also defends a planted parent component - * (--log-file=$plant/log), not just a planted leaf. Used for opens that may - * transit attacker-writable parents: --log-file, --password-file, --*-from, - * --read/write-batch, daemon motd/lock/early-input/--config. - * - * Walks component-by-component with fstatat(AT_SYMLINK_NOFOLLOW) + - * openat(O_NOFOLLOW), splicing a trusted symlink's target back into the path. - * Returns the fd, or -1 (errno ELOOP on the security refusal so callers can - * tell it apart). Falls back to plain open() where openat/O_NOFOLLOW are - * unavailable. */ -/* Core walk. When out_abs is non-NULL and the path resolves to a directory - * (O_DIRECTORY), the resolved absolute path is copied there -- owner_walk_parent - * uses it to filter-check the (otherwise unchecked) leaf basename. */ -static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap) -{ -#if defined AT_FDCWD && defined O_NOFOLLOW - /* O_CLOEXEC predates some still-supported targets; mirror rand_bytes()'s - * fallback in syscall.c so a build without it still compiles. */ -#ifndef O_CLOEXEC -#define O_CLOEXEC 0 -#endif - if (!path || !*path) { - errno = EINVAL; - return -1; - } - - /* Opted out (local --insecure-links, or a daemon module with "insecure - * links = yes"): restore the legacy symlink-following open. */ - if (vfs_symlink_optout_allowed()) - return open(path, flags, mode); - - const uid_t trusted_uid = geteuid(); - int dfd = AT_FDCWD; - int dfd_owns = 0; - - /* Absolute path of the current dir, for the confinement refusal - * (abspath_outside_confinement). A relative operator path starts at the - * daemon's cwd == the module root; an absolute one (or a followed absolute - * symlink target) restarts at "/". */ - char abspath[MAXPATHLEN]; - abspath[0] = '\0'; - if (am_daemon && module_dir && module_dir[0] == '/') - strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */ - else if (confine_root) { - /* Unlike a daemon's, this cwd is not pinned to the root -- the receiver - * chdir's into the destination -- so it has to be read, not assumed. - * It must be the PHYSICAL cwd: curr_dir is the lexical name change_dir() - * was given, so after descending a trusted symlink the tracker sits at a - * different depth than the kernel, and a ".." that really escapes looks - * like it landed inside. - * - * Without it there is nothing to measure against, and an empty tracker - * does NOT deny by itself -- a leading ".." pops nothing and an empty - * path reads as an ancestor of the root -- so refuse the open instead. */ - if (!getcwd(abspath, sizeof abspath)) - return -1; - } - - /* An fd pin (rrsync rewrites an option path to /proc/self/fd/N so no - * later symlink can redirect it) is spelled outside the root by - * construction, so the walk has to be allowed through /proc/self/fd to - * reach the magic link. This only suspends the check for that prefix: - * following the link restarts the walk at its absolute target, and every - * component of THAT is checked, so a pin aimed outside is still refused. */ - int pin_transit = !am_daemon && confine_root && fd_pin_tail(path) != NULL; - - /* Path-walk state. `remaining` is the unconsumed tail; we splice - * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a - * one-level expansion can't immediately overflow; deeper chains - * fail with ENAMETOOLONG below. */ - char remaining[MAXPATHLEN * 2]; - if (strlcpy(remaining, path, sizeof remaining) >= sizeof remaining) { - errno = ENAMETOOLONG; - return -1; - } - - /* Absolute path: pin "/" as the starting dfd. */ - if (remaining[0] == '/') { - dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (dfd < 0) - return -1; - dfd_owns = 1; - abspath[0] = '\0'; /* now resolving from "/" */ - char *p = remaining; - while (*p == '/') p++; - memmove(remaining, p, strlen(p) + 1); - } - - int loops = 40; /* SYMLOOP_MAX-ish; breaks symlink cycles. Counts symlink - * expansions only (below), NOT path depth -- a deep but - * symlink-free path must resolve, not ELOOP. */ - int retfd = -1; - int saved_errno = 0; - - while (*remaining) { - /* Peel one component off the front of `remaining`. */ - char *slash = strchr(remaining, '/'); - size_t comp_len = slash ? (size_t)(slash - remaining) : strlen(remaining); - char comp[MAXPATHLEN]; - if (comp_len == 0 || comp_len >= sizeof comp) { - saved_errno = comp_len == 0 ? EINVAL : ENAMETOOLONG; - goto out; - } - memcpy(comp, remaining, comp_len); - comp[comp_len] = '\0'; - int is_last = (slash == NULL); - - /* Inspect this component without following symlinks. */ - STRUCT_STAT lst; - if (fstatat(dfd, comp, &lst, AT_SYMLINK_NOFOLLOW) < 0) { - /* The leaf may not exist yet (O_CREAT case). Allow it - * and openat with O_NOFOLLOW so a race-planted leaf - * symlink at this instant is still refused. */ - if (is_last && errno == ENOENT && (flags & O_CREAT)) { - if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { - saved_errno = errno; - goto out; - } - if (!pin_transit && abspath_outside_confinement(abspath)) { - saved_errno = ELOOP; - goto out; - } - retfd = openat(dfd, comp, flags | O_NOFOLLOW, mode); - saved_errno = errno; - goto out; - } - saved_errno = errno; - goto out; - } - - if (S_ISLNK(lst.st_mode)) { - /* Symlink: untrusted owner is refused; trusted owner - * is followed via readlinkat + splice. */ - if (lst.st_uid != 0 && lst.st_uid != trusted_uid) { - saved_errno = ELOOP; - goto out; - } - if (--loops < 0) { /* cap symlink-follow chains */ - saved_errno = ELOOP; - goto out; - } - char target[MAXPATHLEN]; - ssize_t n = readlinkat(dfd, comp, target, sizeof target - 1); - if (n < 0) { - saved_errno = errno; - goto out; - } - target[n] = '\0'; - - /* Splice: new `remaining` = + . - * Absolute target restarts the walk from "/". */ - char tail[MAXPATHLEN]; - tail[0] = '\0'; - if (slash) - strlcpy(tail, slash, sizeof tail); - - char rebuilt[MAXPATHLEN * 2]; - if (snprintf(rebuilt, sizeof rebuilt, "%s%s", - target, tail) >= (int)sizeof rebuilt) { - saved_errno = ENAMETOOLONG; - goto out; - } - - if (target[0] == '/') { - if (dfd_owns) close(dfd); - dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); - if (dfd < 0) { - saved_errno = errno; - dfd_owns = 0; - goto out; - } - dfd_owns = 1; - abspath[0] = '\0'; /* followed an absolute target: restart from "/" */ - /* "self" resolves to "", still inside the pin; - * the magic link itself lands elsewhere and ends the - * exemption. Never turns back on. */ - pin_transit = pin_transit && fd_pin_tail(rebuilt) != NULL; - char *p = rebuilt; - while (*p == '/') p++; - strlcpy(remaining, p, sizeof remaining); - } else { - strlcpy(remaining, rebuilt, sizeof remaining); - } - continue; - } - - /* Non-symlink. */ - if (is_last) { - if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { - saved_errno = errno; - goto out; - } - if (!pin_transit && abspath_outside_confinement(abspath)) { - saved_errno = ELOOP; - goto out; - } - retfd = openat(dfd, comp, flags | O_NOFOLLOW, mode); - saved_errno = errno; - /* Resolved leaf dir (O_DIRECTORY): hand its path back so - * owner_walk_parent can filter-check the operation's leaf. */ - if (retfd >= 0 && out_abs && out_cap) - /* Root-resolved (".." popped abspath empty) tracked daemon walk: - * hand back "/" so owner_walk_parent still leaf-checks (path=/ bypass). */ - strlcpy(out_abs, (am_daemon && !abspath[0]) ? "/" : abspath, out_cap); - goto out; - } - - if (!S_ISDIR(lst.st_mode)) { - saved_errno = ENOTDIR; - goto out; - } - /* track the resolved path so a target outside the module is refused */ - if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { - saved_errno = errno; - goto out; - } - if (!pin_transit && abspath_outside_confinement(abspath)) { - saved_errno = ELOOP; - goto out; - } - int next = openat(dfd, comp, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - if (next < 0) { - saved_errno = errno; - goto out; - } - if (dfd_owns) close(dfd); - dfd = next; - dfd_owns = 1; - - /* Advance `remaining` past this component (and the slash). */ - if (slash) { - char *p = slash; - while (*p == '/') p++; - memmove(remaining, p, strlen(p) + 1); - } else { - remaining[0] = '\0'; - } - } - - /* Path resolved entirely to a directory (no leaf component left). - * If the caller wanted O_DIRECTORY we already hold the dirfd we - * built up; otherwise it's an EISDIR. */ - if (flags & O_DIRECTORY) { - retfd = dfd; - dfd_owns = 0; /* caller now owns it */ - saved_errno = 0; - if (out_abs && out_cap) - /* Root-resolved (".." popped abspath empty) tracked daemon walk: - * hand back "/" so owner_walk_parent still leaf-checks (path=/ bypass). */ - strlcpy(out_abs, (am_daemon && !abspath[0]) ? "/" : abspath, out_cap); - } else { - saved_errno = EISDIR; - } - -out: - if (dfd_owns) close(dfd); - errno = saved_errno; - return retfd; -#else - /* Pre-AT_FDCWD / no O_NOFOLLOW systems: best-effort fallback. */ - (void)out_abs; (void)out_cap; - return open(path, flags, mode); -#endif -} - -int open_no_attacker_symlinks(const char *path, int flags, mode_t mode) -{ - return ona_open(path, flags, mode, NULL, 0); -} - -/* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied - * directory path (an absolute or relative --backup-dir/--temp-dir/--*-dest) - * using the ownership walk -- follow a symlink owned by uid 0 or our euid, - * refuse any other-uid one, at every component -- instead of the stricter - * transfer-path resolver (which refuses all symlinks and is confined beneath the - * transfer root). An operator path may legitimately point outside the tree, so - * the trust signal is authority (ownership), not location. Set around the - * relevant ops by backup.c et al.; the opt-out (--insecure-links / "insecure - * links =") restores legacy following. Default 0 (transfer-path resolver). */ -int operator_path_resolve = 0; - -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY -/* For an operator-supplied path: open its parent directory via the ownership - * walk (handles absolute and relative paths) and point *bname at the final - * component. Returns the dirfd (caller closes) or -1 with errno set. */ -int owner_walk_parent(const char *path, const char **bname) -{ - const char *slash = strrchr(path, '/'); - char dir[MAXPATHLEN], pabs[MAXPATHLEN]; - size_t dlen; - int dfd; - - *bname = slash ? slash + 1 : path; - pabs[0] = '\0'; - if (!slash) - dfd = ona_open(".", O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); - else { - dlen = slash == path ? 1 : (size_t)(slash - path); /* "/x" -> parent "/" */ - if (dlen >= sizeof dir) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dir, path, dlen); - dir[dlen] = '\0'; - dfd = ona_open(dir, O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); - } - if (dfd < 0) - return -1; - /* owner_walk only resolved the PARENT; check the resolved leaf too, so a - * symlinked operator path cannot act on a leaf that resolves OUTSIDE the - * module in an otherwise-served dir. (The module exclude/filter is name- - * based and not enforced here -- see abspath_outside_confinement.) */ - if (pabs[0]) { - char leafabs[MAXPATHLEN]; - if (snprintf(leafabs, sizeof leafabs, "%s/%s", pabs, *bname) >= (int)sizeof leafabs) { - close(dfd); - errno = ENAMETOOLONG; /* fail closed, never skip the check */ - return -1; - } - if (abspath_outside_confinement(leafabs)) { - close(dfd); - errno = ELOOP; - return -1; - } - } - return dfd; -} -#endif - #ifndef S_BLKSIZE # if defined hpux || defined __hpux__ || defined __hpux # define S_BLKSIZE 1024 @@ -479,7 +118,6 @@ int do_unlink(const char *path) int do_unlink_at(const char *path) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -494,7 +132,7 @@ int do_unlink_at(const char *path) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return unlink(path); - dfd = owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname); if (dfd < 0) return -1; ret = unlinkat(dfd, bname, 0); @@ -586,7 +224,6 @@ int do_symlink(const char *lnk, const char *path) int do_symlink_at(const char *lnk, const char *path) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -758,7 +395,6 @@ int do_link(const char *old_path, const char *new_path) int do_link_at(const char *old_path, const char *new_path) { #if defined AT_FDCWD && defined HAVE_LINKAT - extern int am_daemon; char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; const char *old_slash, *new_slash; @@ -782,10 +418,10 @@ int do_link_at(const char *old_path, const char *new_path) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_link(old_path, new_path); - old_dfd = owner_walk_parent(old_path, &old_bname); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname); if (old_dfd < 0) return -1; - new_dfd = owner_walk_parent(new_path, &new_bname); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname); if (new_dfd < 0) { e = errno; close(old_dfd); @@ -927,7 +563,6 @@ int do_lchown(const char *path, uid_t owner, gid_t group) int do_lchown_at(const char *fname, uid_t owner, gid_t group) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1057,7 +692,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so * the at-variant won't build there; fall back to do_mknod() (#896). */ #if defined(AT_FDCWD) && defined(HAVE_MKNODAT) - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1072,7 +706,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_mknod(pathname, mode, dev); - dfd = owner_walk_parent(pathname, &bname); + dfd = vfs_owner_walk_parent(pathname, &bname); if (dfd < 0) return -1; if (am_root < 0) { @@ -1204,7 +838,6 @@ int do_rmdir(const char *pathname) int do_rmdir_at(const char *pathname) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1299,7 +932,6 @@ int do_open(const char *pathname, int flags, mode_t mode) int do_open_at(const char *pathname, int flags, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1315,7 +947,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_open(pathname, flags, mode); - dfd = owner_walk_parent(pathname, &bname); + dfd = vfs_owner_walk_parent(pathname, &bname); if (dfd < 0) return -1; ret = openat(dfd, bname, flags | O_NOFOLLOW, mode); @@ -1572,7 +1204,6 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) int do_chmod_at(const char *fname, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1668,7 +1299,6 @@ int do_rename(const char *old_path, const char *new_path) int do_rename_at(const char *old_path, const char *new_path) { #ifdef AT_FDCWD - extern int am_daemon; char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; const char *old_slash, *new_slash; @@ -1693,10 +1323,10 @@ int do_rename_at(const char *old_path, const char *new_path) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_rename(old_path, new_path); - old_dfd = owner_walk_parent(old_path, &old_bname); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname); if (old_dfd < 0) return -1; - new_dfd = owner_walk_parent(new_path, &new_bname); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname); if (new_dfd < 0) { e = errno; close(old_dfd); @@ -1868,7 +1498,6 @@ int do_mkdir(char *path, mode_t mode) int do_mkdir_at(char *path, mode_t mode) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -1884,7 +1513,7 @@ int do_mkdir_at(char *path, mode_t mode) if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return mkdir(path, mode); - dfd = owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname); if (dfd < 0) return -1; ret = mkdirat(dfd, bname, mode); @@ -1999,7 +1628,6 @@ int do_lstat(const char *path, STRUCT_STAT *st) static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) { #ifdef AT_FDCWD - extern int am_daemon; char dirpath[MAXPATHLEN]; const char *bname; const char *slash; @@ -2010,7 +1638,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa if (operator_path_resolve) { if (vfs_symlink_optout_allowed()) return fallback(path, st); - dfd = owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname); if (dfd < 0) return -1; ret = fstatat(dfd, bname, st, at_flags); @@ -2246,7 +1874,6 @@ int do_utimensat(const char *path, STRUCT_STAT *stp) int do_utimensat_at(const char *path, STRUCT_STAT *stp) { #ifdef AT_FDCWD - extern int am_daemon; struct timespec t[2]; char dirpath[MAXPATHLEN]; const char *bname; @@ -2660,7 +2287,7 @@ int secure_mkstemp(char *template, mode_t perms, int operator_path) dir = dirbuf; } dirfd = operator_path - ? open_no_attacker_symlinks(dir, O_RDONLY | O_DIRECTORY, 0) + ? vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0) : vfs_resolve_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); if (dirfd < 0) return -1; @@ -2717,7 +2344,6 @@ int do_open_checklinks(const char *pathname) int open_dir_secure(const char *dirname) { #ifdef AT_FDCWD - extern int am_daemon; int dfd; /* Authority gate, identical to the do_*_at() wrappers. When hardened diff --git a/util1.c b/util1.c index ff119a97e..8df747033 100644 --- a/util1.c +++ b/util1.c @@ -1218,7 +1218,7 @@ int change_dir(const char *dir, int set_path_only) } if (!set_path_only) { /* The destination is operator-supplied (like --log-file et al.), so - * resolve it with open_no_attacker_symlinks: walk each component + * resolve it with vfs_open_owner_walk: walk each component * refusing a symlink not owned by uid 0 or our euid, then fchdir to * the result. This still follows the operator's/root's own symlinked * dest -- the `/backup -> /mnt/disk` / `/var/www -> /srv/www` admin @@ -1228,7 +1228,7 @@ int change_dir(const char *dir, int set_path_only) * non-daemon receiver can opt back into the legacy plain chdir with * --insecure-links. */ if (am_daemon && !am_chrooted) { - int dfd = open_no_attacker_symlinks(dir, O_RDONLY | O_DIRECTORY, 0); + int dfd = vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return 0; if (fchdir(dfd) != 0) { @@ -1259,7 +1259,7 @@ int change_dir(const char *dir, int set_path_only) * another uid. A real dir is opened directly. This closes the * destination chdir TOCTOU; --insecure-links keeps the plain * chdir for an operator whose dest is a foreign-owned symlink. */ - dfd = open_no_attacker_symlinks(nf, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_open_owner_walk(nf, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return 0; if (fchdir(dfd) != 0) { @@ -1339,7 +1339,7 @@ int change_dir(const char *dir, int set_path_only) * symlink not owned by uid 0 or our euid, closing the * relative-dest chdir TOCTOU while still following the operator's * own symlinks. --insecure-links keeps the plain chdir. */ - int dfd = open_no_attacker_symlinks(curr_dir, + int dfd = vfs_open_owner_walk(curr_dir, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) chdir_failed = 1; diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c new file mode 100644 index 000000000..046f362d5 --- /dev/null +++ b/vfs/owner_walk.c @@ -0,0 +1,370 @@ +/* + * vfs/owner_walk.c - operator-supplied-path resolution by ownership. + * + * For operator paths (--backup-dir/--temp-dir/--*-dest, daemon + * --log-file/motd/lock/config, etc.) that may legitimately point outside the + * transfer tree, the trust signal is authority not location: follow a symlink + * owned by uid 0 or our euid at every component, refuse any other-uid one. + * vfs_open_owner_walk() opens such a path; vfs_vfs_owner_walk_parent() opens its + * parent. Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +extern int am_sender; +extern int insecure_links; + + +/* Advance the tracked absolute path `abspath` by one resolved component, + * normalizing "." and ".." exactly as openat() does so the module-confinement + * check (abspath_excluded_by_module) sees the REAL resolved target. -1/ + * ENAMETOOLONG on overflow. */ +static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp_len) +{ + if (comp_len == 1 && comp[0] == '.') + return 0; /* "." -- no movement */ + if (comp_len == 2 && comp[0] == '.' && comp[1] == '.') { + char *s = strrchr(abspath, '/'); /* ".." -- pop a component */ + if (s) + *s = '\0'; + else + abspath[0] = '\0'; + return 0; + } + size_t al = strlen(abspath); + size_t off = (al > 0 && abspath[al-1] == '/') ? al : al + 1; /* no "//" */ + if (off + comp_len >= cap) { + errno = ENAMETOOLONG; + return -1; + } + if (off != al) + abspath[al] = '/'; + memcpy(abspath + off, comp, comp_len + 1); + return 0; +} + +/* Open an operator-supplied path, refusing to traverse any symlink (parent or + * leaf) not owned by uid 0 or our euid. A trusted-owned symlink (e.g. root's + * /var/log -> /data/log) is still followed; an untrusted one fails ELOOP. + * Unlike plain O_NOFOLLOW this also defends a planted parent component + * (--log-file=$plant/log), not just a planted leaf. Used for opens that may + * transit attacker-writable parents: --log-file, --password-file, --*-from, + * --read/write-batch, daemon motd/lock/early-input/--config. + * + * Walks component-by-component with fstatat(AT_SYMLINK_NOFOLLOW) + + * openat(O_NOFOLLOW), splicing a trusted symlink's target back into the path. + * Returns the fd, or -1 (errno ELOOP on the security refusal so callers can + * tell it apart). Falls back to plain open() where openat/O_NOFOLLOW are + * unavailable. */ +/* Core walk. When out_abs is non-NULL and the path resolves to a directory + * (O_DIRECTORY), the resolved absolute path is copied there -- vfs_owner_walk_parent + * uses it to filter-check the (otherwise unchecked) leaf basename. */ +static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap) +{ +#if defined AT_FDCWD && defined O_NOFOLLOW + /* O_CLOEXEC predates some still-supported targets; mirror rand_bytes()'s + * fallback in syscall.c so a build without it still compiles. */ +#ifndef O_CLOEXEC +#define O_CLOEXEC 0 +#endif + if (!path || !*path) { + errno = EINVAL; + return -1; + } + + /* Opted out (local --insecure-links, or a daemon module with "insecure + * links = yes"): restore the legacy symlink-following open. */ + if (vfs_symlink_optout_allowed()) + return open(path, flags, mode); + + const uid_t trusted_uid = geteuid(); + int dfd = AT_FDCWD; + int dfd_owns = 0; + + /* Absolute module-relative path of the current dir, for the exclude-aware + * refusal (abspath_excluded_by_module). A relative operator path starts at + * the daemon's cwd == the module root; an absolute one (or a followed + * absolute symlink target) restarts at "/". */ + char abspath[MAXPATHLEN]; + abspath[0] = '\0'; + if (am_daemon && module_dir && module_dir[0] == '/') + strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */ + + /* Path-walk state. `remaining` is the unconsumed tail; we splice + * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a + * one-level expansion can't immediately overflow; deeper chains + * fail with ENAMETOOLONG below. */ + char remaining[MAXPATHLEN * 2]; + if (strlcpy(remaining, path, sizeof remaining) >= sizeof remaining) { + errno = ENAMETOOLONG; + return -1; + } + + /* Absolute path: pin "/" as the starting dfd. */ + if (remaining[0] == '/') { + dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dfd < 0) + return -1; + dfd_owns = 1; + abspath[0] = '\0'; /* now resolving from "/" */ + char *p = remaining; + while (*p == '/') p++; + memmove(remaining, p, strlen(p) + 1); + } + + int loops = 40; /* SYMLOOP_MAX-ish; breaks symlink cycles. Counts symlink + * expansions only (below), NOT path depth -- a deep but + * symlink-free path must resolve, not ELOOP. */ + int retfd = -1; + int saved_errno = 0; + + while (*remaining) { + /* Peel one component off the front of `remaining`. */ + char *slash = strchr(remaining, '/'); + size_t comp_len = slash ? (size_t)(slash - remaining) : strlen(remaining); + char comp[MAXPATHLEN]; + if (comp_len == 0 || comp_len >= sizeof comp) { + saved_errno = comp_len == 0 ? EINVAL : ENAMETOOLONG; + goto out; + } + memcpy(comp, remaining, comp_len); + comp[comp_len] = '\0'; + int is_last = (slash == NULL); + + /* Inspect this component without following symlinks. */ + STRUCT_STAT lst; + if (fstatat(dfd, comp, &lst, AT_SYMLINK_NOFOLLOW) < 0) { + /* The leaf may not exist yet (O_CREAT case). Allow it + * and openat with O_NOFOLLOW so a race-planted leaf + * symlink at this instant is still refused. */ + if (is_last && errno == ENOENT && (flags & O_CREAT)) { + if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { + saved_errno = errno; + goto out; + } + if (abspath_excluded_by_module(abspath, 0)) { + saved_errno = ELOOP; + goto out; + } + retfd = openat(dfd, comp, flags | O_NOFOLLOW, mode); + saved_errno = errno; + goto out; + } + saved_errno = errno; + goto out; + } + + if (S_ISLNK(lst.st_mode)) { + /* Symlink: untrusted owner is refused; trusted owner + * is followed via readlinkat + splice. */ + if (lst.st_uid != 0 && lst.st_uid != trusted_uid) { + saved_errno = ELOOP; + goto out; + } + if (--loops < 0) { /* cap symlink-follow chains */ + saved_errno = ELOOP; + goto out; + } + char target[MAXPATHLEN]; + ssize_t n = readlinkat(dfd, comp, target, sizeof target - 1); + if (n < 0) { + saved_errno = errno; + goto out; + } + target[n] = '\0'; + + /* Splice: new `remaining` = + . + * Absolute target restarts the walk from "/". */ + char tail[MAXPATHLEN]; + tail[0] = '\0'; + if (slash) + strlcpy(tail, slash, sizeof tail); + + char rebuilt[MAXPATHLEN * 2]; + if (snprintf(rebuilt, sizeof rebuilt, "%s%s", + target, tail) >= (int)sizeof rebuilt) { + saved_errno = ENAMETOOLONG; + goto out; + } + + if (target[0] == '/') { + if (dfd_owns) close(dfd); + dfd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dfd < 0) { + saved_errno = errno; + dfd_owns = 0; + goto out; + } + dfd_owns = 1; + abspath[0] = '\0'; /* followed an absolute target: restart from "/" */ + char *p = rebuilt; + while (*p == '/') p++; + strlcpy(remaining, p, sizeof remaining); + } else { + strlcpy(remaining, rebuilt, sizeof remaining); + } + continue; + } + + /* Non-symlink. */ + if (is_last) { + if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { + saved_errno = errno; + goto out; + } + if (abspath_excluded_by_module(abspath, S_ISDIR(lst.st_mode))) { + saved_errno = ELOOP; + goto out; + } + retfd = openat(dfd, comp, flags | O_NOFOLLOW, mode); + saved_errno = errno; + /* Resolved leaf dir (O_DIRECTORY): hand its path back so + * vfs_owner_walk_parent can filter-check the operation's leaf. */ + if (retfd >= 0 && out_abs && out_cap) + /* Root-resolved (".." popped abspath empty) tracked daemon walk: + * hand back "/" so vfs_owner_walk_parent still leaf-checks (path=/ bypass). */ + strlcpy(out_abs, (am_daemon && !abspath[0]) ? "/" : abspath, out_cap); + goto out; + } + + if (!S_ISDIR(lst.st_mode)) { + saved_errno = ENOTDIR; + goto out; + } + /* track the resolved path so a target outside the module is refused */ + if (abspath_step(abspath, sizeof abspath, comp, comp_len) < 0) { + saved_errno = errno; + goto out; + } + if (abspath_excluded_by_module(abspath, 1)) { + saved_errno = ELOOP; + goto out; + } + int next = openat(dfd, comp, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) { + saved_errno = errno; + goto out; + } + if (dfd_owns) close(dfd); + dfd = next; + dfd_owns = 1; + + /* Advance `remaining` past this component (and the slash). */ + if (slash) { + char *p = slash; + while (*p == '/') p++; + memmove(remaining, p, strlen(p) + 1); + } else { + remaining[0] = '\0'; + } + } + + /* Path resolved entirely to a directory (no leaf component left). + * If the caller wanted O_DIRECTORY we already hold the dirfd we + * built up; otherwise it's an EISDIR. */ + if (flags & O_DIRECTORY) { + retfd = dfd; + dfd_owns = 0; /* caller now owns it */ + saved_errno = 0; + if (out_abs && out_cap) + /* Root-resolved (".." popped abspath empty) tracked daemon walk: + * hand back "/" so vfs_owner_walk_parent still leaf-checks (path=/ bypass). */ + strlcpy(out_abs, (am_daemon && !abspath[0]) ? "/" : abspath, out_cap); + } else { + saved_errno = EISDIR; + } + +out: + if (dfd_owns) close(dfd); + errno = saved_errno; + return retfd; +#else + /* Pre-AT_FDCWD / no O_NOFOLLOW systems: best-effort fallback. */ + (void)out_abs; (void)out_cap; + return open(path, flags, mode); +#endif +} + +int vfs_open_owner_walk(const char *path, int flags, mode_t mode) +{ + return ona_open(path, flags, mode, NULL, 0); +} + +/* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied + * directory path (an absolute or relative --backup-dir/--temp-dir/--*-dest) + * using the ownership walk -- follow a symlink owned by uid 0 or our euid, + * refuse any other-uid one, at every component -- instead of the stricter + * transfer-path resolver (which refuses all symlinks and is confined beneath the + * transfer root). An operator path may legitimately point outside the tree, so + * the trust signal is authority (ownership), not location. Set around the + * relevant ops by backup.c et al.; the opt-out (--insecure-links / "insecure + * links =") restores legacy following. Default 0 (transfer-path resolver). */ +int operator_path_resolve = 0; + +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY +/* For an operator-supplied path: open its parent directory via the ownership + * walk (handles absolute and relative paths) and point *bname at the final + * component. Returns the dirfd (caller closes) or -1 with errno set. */ +int vfs_owner_walk_parent(const char *path, const char **bname) +{ + const char *slash = strrchr(path, '/'); + char dir[MAXPATHLEN], pabs[MAXPATHLEN]; + size_t dlen; + int dfd; + + *bname = slash ? slash + 1 : path; + pabs[0] = '\0'; + if (!slash) + dfd = ona_open(".", O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); + else { + dlen = slash == path ? 1 : (size_t)(slash - path); /* "/x" -> parent "/" */ + if (dlen >= sizeof dir) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dir, path, dlen); + dir[dlen] = '\0'; + dfd = ona_open(dir, O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); + } + if (dfd < 0) + return -1; + /* owner_walk only resolved the PARENT; check the resolved leaf too, so a + * symlinked operator path cannot act on a leaf that resolves OUTSIDE the + * module in an otherwise-served dir. (The module exclude/filter is name- + * based and not enforced here -- see abspath_excluded_by_module.) */ + if (pabs[0]) { + char leafabs[MAXPATHLEN]; + STRUCT_STAT lst; + int isdir = 0, absent = 0, refuse; + if (fstatat(dfd, *bname, &lst, AT_SYMLINK_NOFOLLOW) == 0) + isdir = S_ISDIR(lst.st_mode); + else + absent = 1; /* mkdir/rename target: type unknown yet */ + if (snprintf(leafabs, sizeof leafabs, "%s/%s", pabs, *bname) >= (int)sizeof leafabs) { + close(dfd); + errno = ENAMETOOLONG; /* fail closed, never skip the check */ + return -1; + } + /* For an absent leaf the op may create a dir, so also test dir-only + * filter rules (a "/foo/" rule never matches a file). */ + refuse = abspath_excluded_by_module(leafabs, isdir) + || (absent && abspath_excluded_by_module(leafabs, 1)); + if (refuse) { + close(dfd); + errno = ELOOP; + return -1; + } + } + return dfd; +} +#endif diff --git a/vfs/vfs.h b/vfs/vfs.h index ee86c7f16..d23cac649 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -63,4 +63,8 @@ int vfs_symlink_optout_allowed(void); int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode); int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode); +/* Operator-supplied-path resolution by ownership (vfs/owner_walk.c). */ +int vfs_open_owner_walk(const char *path, int flags, mode_t mode); +int vfs_owner_walk_parent(const char *path, const char **bname); + #endif /* RSYNC_VFS_H */ From a5f52ee62e59e0e3a429028c5bb773b8f7293f6a Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:08:03 +1000 Subject: [PATCH 05/69] vfs: move the held-dirfd cache into vfs/dircache.c Relocate the persistent ancestor-dirfd cache out of syscall.c into vfs/dircache.c with the vfs_* public names: open_dir_secure -> vfs_opendir get_dir_fd -> vfs_get_dirfd held_dir_path_fd -> vfs_path_dirfd held_dfd_for -> vfs_cached_dirfd reset_dir_fd_cache -> vfs_dircache_reset The dpc_* cache statics and dpc_dir_fd stay file-local (they fold into the vfs struct in a later commit). Function bodies are unchanged; the callers in generator/receiver/sender/delete/util1/rsync are updated and the five entry points declared in vfs/vfs.h. This completes moving the security core (resolver, owner-walk, dirfd cache) out of syscall.c. No behavior change. --- Makefile.in | 2 +- delete.c | 2 +- generator.c | 28 +++--- receiver.c | 9 +- rsync.c | 4 +- sender.c | 32 +------ syscall.c | 226 ------------------------------------------- util1.c | 6 +- vfs/dircache.c | 253 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 7 ++ 10 files changed, 288 insertions(+), 281 deletions(-) create mode 100644 vfs/dircache.c diff --git a/Makefile.in b/Makefile.in index 59c96f3a0..7342a7a2c 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/delete.c b/delete.c index 83ebb9e27..d9eee6383 100644 --- a/delete.c +++ b/delete.c @@ -133,7 +133,7 @@ static enum delret delete_dir_contents(char *fname, uint16 flags) const char *save_del_prefix = del_dir_prefix; int save_del_prefix_len = del_dir_prefix_len; fname[dlen] = '\0'; - del_dirfd = open_dir_secure(fname); + del_dirfd = vfs_opendir(fname); fname[dlen] = '/'; del_dir_prefix = fname; del_dir_prefix_len = dlen; diff --git a/generator.c b/generator.c index beffa724f..5d77dfba4 100644 --- a/generator.c +++ b/generator.c @@ -1382,8 +1382,8 @@ static BOOL is_below(struct file_struct *file, struct file_struct *subtree) /* Held-dirfd helpers for the per-entry ops below: when the secure resolver is * active they act on the entry's basename relative to its cached directory fd - * (held_dfd_for, keyed on file->dirname), else fall back to the full-path - * do_*_at wrappers (behaviour-identical). held_dfd_for() declines when fname + * (vfs_cached_dirfd, keyed on file->dirname), else fall back to the full-path + * do_*_at wrappers (behaviour-identical). vfs_cached_dirfd() declines when fname * isn't in file->dirname (e.g. the single-file local_name dest), and the leaf * is derived from fname, not file->basename. */ static int gen_entry_stat(const char *fname, struct file_struct *file, @@ -1392,7 +1392,7 @@ static int gen_entry_stat(const char *fname, struct file_struct *file, int dfd; /* link_stat_at folds in no fake-super xattr, so only use it when * am_root >= 0 (where link_stat's get_stat_xattr is a no-op anyway). */ - if (am_root >= 0 && (dfd = held_dfd_for(fname, file)) >= 0) { + if (am_root >= 0 && (dfd = vfs_cached_dirfd(fname, file)) >= 0) { const char *slash = strrchr(fname, '/'); return link_stat_at(dfd, slash ? slash + 1 : fname, stp, follow_dirlinks); } @@ -1401,7 +1401,7 @@ static int gen_entry_stat(const char *fname, struct file_struct *file, static int gen_entry_mkdir(char *fname, struct file_struct *file, mode_t mode) { - int dfd = held_dfd_for(fname, file); + int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); return do_mkdir_atfd(dfd, slash ? slash + 1 : fname, mode); @@ -1411,7 +1411,7 @@ static int gen_entry_mkdir(char *fname, struct file_struct *file, mode_t mode) static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t mode) { - int dfd = held_dfd_for(fname, file); + int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); return do_chmod_atfd(dfd, slash ? slash + 1 : fname, mode); @@ -1421,7 +1421,7 @@ static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t m static void gen_entry_set_times(const char *fname, struct file_struct *file, STRUCT_STAT *stp) { - int dfd = held_dfd_for(fname, file); + int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); if (set_times_at(dfd, slash ? slash + 1 : fname, stp) != -2) @@ -1432,7 +1432,7 @@ static void gen_entry_set_times(const char *fname, struct file_struct *file, STR static int gen_entry_symlink(const char *slnk, const char *path, struct file_struct *file) { - int dfd = held_dfd_for(path, file); + int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); return do_symlink_atfd(slnk, dfd, slash ? slash + 1 : path); @@ -1472,7 +1472,7 @@ static int gen_entry_mknod(const char *path, struct file_struct *file, mode_t mo { int dfd; /* do_mknod_atfd can't create a socket (no portable bindat); fall back. */ - if (!S_ISSOCK(mode) && (dfd = held_dfd_for(path, file)) >= 0) { + if (!S_ISSOCK(mode) && (dfd = vfs_cached_dirfd(path, file)) >= 0) { const char *slash = strrchr(path, '/'); int ret = do_mknod_atfd(dfd, slash ? slash + 1 : path, mode, rdev); /* Fall through to the unconfined path-based create only where this @@ -1490,7 +1490,7 @@ static int gen_entry_mknod(const char *path, struct file_struct *file, mode_t mo static int gen_entry_unlink(const char *path, struct file_struct *file) { - int dfd = held_dfd_for(path, file); + int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); return do_unlink_atfd(dfd, slash ? slash + 1 : path, 0); @@ -1503,8 +1503,8 @@ static int gen_entry_unlink(const char *path, struct file_struct *file) * single renameat() within it, else fall back to the full-path wrapper. */ static int gen_entry_rename(const char *opath, const char *npath, struct file_struct *file) { - int odfd = held_dfd_for(opath, file); - int ndfd = held_dfd_for(npath, file); + int odfd = vfs_cached_dirfd(opath, file); + int ndfd = vfs_cached_dirfd(npath, file); if (odfd >= 0 && ndfd >= 0) { const char *os = strrchr(opath, '/'); const char *ns = strrchr(npath, '/'); @@ -1522,8 +1522,8 @@ static int gen_entry_rename(const char *opath, const char *npath, struct file_st * set_file_attrs' held-fd handling). */ static int gen_entry_copy_xattrs(const char *src, const char *fname, struct file_struct *file) { - int dfd = held_dfd_for(fname, file); - int xfd = -1, sfd = -1, ret; + int dfd = vfs_cached_dirfd(fname, file); + int xfd = -1, ret; if (dfd >= 0) { const char *slash = strrchr(fname, '/'); xfd = openat(dfd, slash ? slash + 1 : fname, @@ -1873,7 +1873,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (real_ret != 0 && gen_entry_mkdir(fname, file, file->mode|added_perms) < 0 && errno != EEXIST) { /* The parent may have just been created by make_path(), so * drop any cached (failed) dir fd before the retry. */ - reset_dir_fd_cache(); + vfs_dircache_reset(); if (!relative_paths || errno != ENOENT || make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0 || (gen_entry_mkdir(fname, file, file->mode|added_perms) < 0 && errno != EEXIST)) { diff --git a/receiver.c b/receiver.c index 5df9b75e5..6e293cc55 100644 --- a/receiver.c +++ b/receiver.c @@ -426,7 +426,7 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) * (it may legitimately point outside the tree); the deep-entry-dir fallback, * when the held-dirfd cache declines, gets the strict transfer-path one. */ if (vfs_relpath_active()) { - int dfd = held_dfd_for(fnametmp, file); + int dfd = vfs_cached_dirfd(fnametmp, file); if (dfd >= 0) { char *slash = strrchr(fnametmp, '/'); fd = do_mkstemp_atfd(dfd, slash ? slash + 1 : fnametmp, @@ -1064,12 +1064,7 @@ int recv_files(int f_in, int f_out, char *local_name) * trusted absolute fnamecmp (e.g. an absolute --partial-dir basis). */ { int bdfd; - if (fnamecmp_type == FNAMECMP_PARTIAL_DIR - && fnamecmp && *fnamecmp != '/') { - /* The relative partial path contains peer-derived directory - * components. It is not an operator-trusted path as a whole. */ - fd1 = secure_relative_open(NULL, fnamecmp, O_RDONLY, 0); - } else if (!basedir && (bdfd = held_dfd_for(fnamecmp, file)) >= 0) { + if (!basedir && (bdfd = vfs_cached_dirfd(fnamecmp, file)) >= 0) { const char *slash; assert(fnamecmp != NULL); /* set on every path above */ slash = strrchr(fnamecmp, '/'); diff --git a/rsync.c b/rsync.c index 19a371d97..1b9c8464d 100644 --- a/rsync.c +++ b/rsync.c @@ -526,7 +526,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, /* Stat through the entry's held dir fd (like gen_entry_stat) so we * don't re-walk the full path here; link_stat_at folds in no * fake-super xattr, so only when am_root >= 0. */ - if (am_root >= 0 && (sdfd = held_dfd_for(fname, file)) >= 0) { + if (am_root >= 0 && (sdfd = vfs_cached_dirfd(fname, file)) >= 0) { const char *sl = strrchr(fname, '/'); sret = link_stat_at(sdfd, sl ? sl + 1 : fname, &sx2.st, 0); } else @@ -546,7 +546,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, * issue single-component *at() calls against it instead of re-resolving * the full path each time. -1 => fall back to the full-path wrappers * (cross-tree path such as --temp-dir/--backup-dir, or gated off). */ - dfd = held_dfd_for(fname, file); + dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); leaf = slash ? slash + 1 : fname; diff --git a/sender.c b/sender.c index 1a6c97442..827a250b5 100644 --- a/sender.c +++ b/sender.c @@ -108,35 +108,13 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, } memcpy(dir, fname, dlen); dir[dlen] = '\0'; - /* An absolute --relative name is still rooted at / after - * change_pathname(). Resolving its parent through the cwd-backed - * dirfd cache would re-anchor cleanup at the sender's working - * directory and can remove a same-named, unrelated entry there. */ - if (*fname == '/') { - const char *rel = dir; -#ifdef __CYGWIN__ - /* clean_fname() keeps exactly two leading slashes here, - * because //server/share is a separate UNC namespace. - * Stripping them and anchoring at "/" would resolve a - * different object entirely, so decline (errno 0) and let - * the caller fall back to the path-based cleanup. */ - if (fname[1] == '/' && fname[2] != '/') { - errno = 0; - return -1; - } -#endif - while (*rel == '/') - rel++; - return secure_relative_open("/", rel, - O_RDONLY | O_DIRECTORY, 0); - } - /* held_dir_path_fd returns a cache-OWNED fd; the caller closes + /* vfs_path_dirfd returns a cache-OWNED fd; the caller closes * what we return, so hand back an owned dup and leave the cache's * dirfd intact. An uncacheable (very deep) dir declines with * errno 0 -- fall back to the full confined walk (an owned fd, * matching the sender's content open) so deep paths stay confined * too; a real error propagates. */ - dfd = held_dir_path_fd(NULL, dir); + dfd = vfs_path_dirfd(NULL, dir); if (dfd >= 0) return dup(dfd); if (errno != 0) @@ -205,10 +183,10 @@ static int secure_remove_source_file(int dfd, const char *bname) /* Open `relpath` (relative to `anchor`: NULL=cwd, else an absolute trusted root) * with `flags`, opening the leaf via the shared held ancestor-dirfd stack - * (held_dir_path_fd) so a directory is walked once, not once per file. The leaf + * (vfs_path_dirfd) so a directory is walked once, not once per file. The leaf * semantics are identical to vfs_resolve_open() -- it always O_NOFOLLOWs a * file leaf and folds in O_NOATIME, both preserved here. An uncacheable path - * (held_dir_path_fd returns -1) falls back to the full confined walk. */ + * (vfs_path_dirfd returns -1) falls back to the full confined walk. */ static int sender_open_confined(const char *anchor, const char *relpath, int flags) { #ifdef AT_FDCWD @@ -237,7 +215,7 @@ static int sender_open_confined(const char *anchor, const char *relpath, int fla if (open_noatime) flags |= O_NOATIME; #endif - dfd = held_dir_path_fd(anchor, dir); + dfd = vfs_path_dirfd(anchor, dir); if (dfd < 0) return vfs_resolve_open(anchor, relpath, flags | O_NOFOLLOW, 0); return openat(dfd, bname, flags | O_NOFOLLOW, 0); diff --git a/syscall.c b/syscall.c index 6e63cc9d3..21b0c6a13 100644 --- a/syscall.c +++ b/syscall.c @@ -2324,232 +2324,6 @@ int do_open_checklinks(const char *pathname) return do_open_nofollow(pathname, O_RDONLY); } -/* Held-directory-fd traversal. - * - * Rather than re-resolve a full path on every syscall (do_*_at() re-opens the - * parent via vfs_resolve_open() each call), the generator and receiver - * open each directory ONCE via open_dir_secure() and issue single-component - * *at() ops against that held dirfd with the do_*_atfd() wrappers below. The - * parent is a pinned fd, not re-resolved, so the per-entry symlink-race window - * is closed and the re-resolution overhead is gone. - * - * open_dir_secure() owns both the authority gate and the resolver choice: it - * returns a held dirfd only when hardened resolution is in effect, else -1 - * with errno==0 so the caller falls back to the do_*_at() wrappers - * (behaviour-neutral). The do_*_atfd() wrappers are thin shims with the same - * leaf semantics as do_*_at() (dry-run/read-only guards, AT_SYMLINK_NOFOLLOW, - * fake-super placeholder files); they never re-check the gate or re-resolve a - * parent. */ - -int open_dir_secure(const char *dirname) -{ -#ifdef AT_FDCWD - int dfd; - - /* Authority gate, identical to the do_*_at() wrappers. When hardened - * resolution isn't in effect, return -1 with errno cleared so the caller - * uses the full-path wrappers. */ - if (!vfs_relpath_active()) { - errno = 0; - return -1; - } - - if (!dirname || !*dirname) { - /* The transfer root itself (file->dirname == NULL): the cwd. */ - dfd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY); - } else if (dirname[0] == '/') { - /* An absolute dirname is not expected for an in-transfer entry; - * leave it to the legacy path. */ - errno = 0; - return -1; - } else { - dfd = vfs_resolve_open(NULL, dirname, O_RDONLY | O_DIRECTORY, 0); - } - - if (dfd >= 0) { - /* O_CLOEXEC on every tier (the per-component walk fallback - * doesn't thread our flags onto the returned dirfd). */ - int fl = fcntl(dfd, F_GETFD); - if (fl >= 0) - fcntl(dfd, F_SETFD, fl | FD_CLOEXEC); - } - return dfd; -#else - (void)dirname; - errno = 0; - return -1; -#endif -} - -/* Persistent ancestor-dirfd stack for held-directory traversal. - * - * The transfer's file list is path-sorted, so iterating it walks the tree in - * DFS order and consecutive directory resolutions share a long leading prefix. - * Rather than re-resolve a full path from the anchor each time (re-opening - * every ancestor dir per file), we keep the whole current ancestor chain open - * as pinned, race-safe dirfds and, on the next resolution, reuse the longest - * common component prefix -- popping only the divergent tail and descending the - * new tail. Each directory is then opened once while we are inside its subtree. - * - * The chain is relative to the process cwd (for a NULL anchor), so change_dir() - * drops it on any real chdir; it otherwise persists across flist chunks (the - * pinned fds stay valid, and a raced/replaced ancestor resolves to the original - * inode the fd holds -- the held-dirfd race-safety property, not a hazard). - * Each component is resolved with ds_descend(), which follows in-tree directory - * symlinks exactly as vfs_resolve_open() does; only the resolved dir fd is - * kept (intermediate symlink-target fds are closed -- sound, since an open - * dirfd needs no live parent). */ -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY -#define DPC_MAXDEPTH 64 -static const char *dpc_anchor = (const char *)-2; -static int dpc_base = -1; /* opened anchor dir (owned), or -1 */ -static int dpc_fd[DPC_MAXDEPTH]; /* dpc_fd[i] = dir after components 0..i */ -static char dpc_name[DPC_MAXDEPTH][256]; /* textual component names */ -static int dpc_depth = 0; - -void reset_dir_fd_cache(void) -{ - while (dpc_depth > 0) - close(dpc_fd[--dpc_depth]); - if (dpc_base >= 0) - close(dpc_base); - dpc_base = -1; - dpc_anchor = (const char *)-2; -} - -/* Resolve directory `dirpath` beneath `anchor` (NULL = cwd, else an absolute - * trusted root), reusing the held ancestor stack. Returns a BORROWED dirfd - * owned by the cache (do NOT close), or -1 (errno preserved for a real open - * error, errno==0 for an uncacheable path -- "..", too deep/long, or a relative - * non-cwd anchor) so the caller can fall back to vfs_resolve_open(). */ -static int dpc_dir_fd(const char *anchor, const char *dirpath) -{ - char copy[MAXPATHLEN]; - char *comps[DPC_MAXDEPTH]; - char *sv = NULL; - int nc = 0, p, i; - - if (anchor && anchor[0] != '/') { errno = 0; return -1; } - if (!dirpath) - dirpath = ""; - if (dirpath[0] == '/') { errno = 0; return -1; } - - if (anchor != dpc_anchor || dpc_base < 0) { - int fl; - reset_dir_fd_cache(); - dpc_base = open_anchor_dirfd(anchor ? anchor : "."); - if (dpc_base < 0) - return -1; - if ((fl = fcntl(dpc_base, F_GETFD)) >= 0) - fcntl(dpc_base, F_SETFD, fl | FD_CLOEXEC); - dpc_anchor = anchor; - } - - if (strlcpy(copy, dirpath, sizeof copy) >= sizeof copy) { errno = ENAMETOOLONG; return -1; } - for (char *c = strtok_r(copy, "/", &sv); c; c = strtok_r(NULL, "/", &sv)) { - if (c[0] == '.' && c[1] == '\0') - continue; /* "." */ - if (c[0] == '.' && c[1] == '.' && c[2] == '\0') { errno = 0; return -1; } - if (nc >= DPC_MAXDEPTH || strlen(c) >= sizeof dpc_name[0]) { - /* Too deep / a too-long component to cache. Release the held - * ancestor fds first so the caller's full-path fallback walk does - * not stack on top of them: a deep tree plus a low RLIMIT_NOFILE - * (e.g. OpenBSD's default 128) would otherwise exhaust descriptors - * (cache depth + walk depth). */ - reset_dir_fd_cache(); - errno = 0; - return -1; - } - comps[nc++] = c; - } - - /* Reuse the longest common prefix; drop the divergent tail. */ - for (p = 0; p < dpc_depth && p < nc && strcmp(dpc_name[p], comps[p]) == 0; p++) - ; - while (dpc_depth > p) - close(dpc_fd[--dpc_depth]); - - /* Descend the new tail, holding each resolved component. */ - for (i = p; i < nc; i++) { - int afd = dpc_depth > 0 ? dpc_fd[dpc_depth-1] : dpc_base; - struct dirstack ds; - int hops = SECURE_OPEN_MAXSYMLINKS; - int fd, fl; - if (ds_init(&ds, afd) < 0) - return -1; - if (ds_descend(&ds, comps[i], &hops) < 0) { - int e = errno; - ds_free(&ds); - errno = e; - return -1; - } - fd = ds_take(&ds); - ds_free(&ds); /* closes intermediate symlink fds, not afd */ - if (fd < 0) - return -1; - if ((fl = fcntl(fd, F_GETFD)) >= 0) - fcntl(fd, F_SETFD, fl | FD_CLOEXEC); - strlcpy(dpc_name[dpc_depth], comps[i], sizeof dpc_name[0]); - dpc_fd[dpc_depth++] = fd; - } - - return nc > 0 ? dpc_fd[dpc_depth-1] : dpc_base; -} - -/* Public entry for the sender (no vfs_relpath_active gate: its send paths - * confine unconditionally). Borrowed fd; -1 => caller uses the full walk. */ -int held_dir_path_fd(const char *anchor, const char *dirpath) -{ - return dpc_dir_fd(anchor, dirpath); -} - -int get_dir_fd(const char *dirname) -{ - if (!vfs_relpath_active()) { errno = 0; return -1; } - return dpc_dir_fd(NULL, dirname); -} -#else -void reset_dir_fd_cache(void) -{ -} -int held_dir_path_fd(const char *anchor, const char *dirpath) -{ - (void)anchor; - (void)dirpath; - errno = 0; - return -1; -} -int get_dir_fd(const char *dirname) -{ - (void)dirname; - errno = 0; - return -1; -} -#endif - -/* Return the cached current-directory fd iff `path` lives directly in the - * entry's own directory (file->dirname) -- the common case for held-dirfd - * traversal. Returns -1 (caller falls back to the do_*_at() wrappers) for - * anything elsewhere: --temp-dir/--partial-dir/--backup-dir, an absolute path, - * a differently-nested dir, or when open_dir_secure() is gated off. The dirfd - * is opened once and cached. - * - * file->basename is NOT assumed to equal `path`'s leaf (a temp file has a - * different basename), so the caller derives the leaf from `path`. */ -int held_dfd_for(const char *path, const struct file_struct *file) -{ - const char *slash, *dn; - size_t plen; - - if (!path || *path == '/') - return -1; - dn = file && file->dirname ? file->dirname : ""; - slash = strrchr(path, '/'); - plen = slash ? (size_t)(slash - path) : 0; - if (strlen(dn) != plen || memcmp(path, dn, plen) != 0) - return -1; - return get_dir_fd(file ? file->dirname : NULL); -} int do_unlink_atfd(int dfd, const char *name, int flags) { diff --git a/util1.c b/util1.c index 8df747033..ab7b5ed00 100644 --- a/util1.c +++ b/util1.c @@ -582,8 +582,8 @@ int robust_rename(const char *from, const char *to, const char *partialptr, while (tries--) { /* tmp -> final usually live in the entry's own dir: rename via the * held dir fd when both do, else the full-path wrapper. */ - int ofd = held_dfd_for(from, file); - int nfd = held_dfd_for(to, file); + int ofd = vfs_cached_dirfd(from, file); + int nfd = vfs_cached_dirfd(to, file); int rr; if (ofd >= 0 && nfd >= 0) { const char *os = strrchr(from, '/'); @@ -1368,7 +1368,7 @@ int change_dir(const char *dir, int set_path_only) } if (!set_path_only) /* a real chdir invalidates the cwd-relative dir-fd stack */ - reset_dir_fd_cache(); + vfs_dircache_reset(); if (DEBUG_GTE(CHDIR, 1) && !set_path_only) rprintf(FINFO, "[%s] change_dir(%s)\n", who_am_i(), curr_dir); diff --git a/vfs/dircache.c b/vfs/dircache.c new file mode 100644 index 000000000..f76910e1e --- /dev/null +++ b/vfs/dircache.c @@ -0,0 +1,253 @@ +/* + * vfs/dircache.c - persistent ancestor-dirfd cache for held-directory traversal. + * + * The file list is path-sorted, so consecutive directory resolutions share a + * long leading prefix. Rather than re-resolve a full path from the anchor per + * file, we keep the whole current ancestor chain open as pinned, race-safe + * dirfds and reuse the longest common component prefix on the next resolution. + * vfs_opendir() hands out a held dirfd (or -1 to fall back); vfs_dircache_reset() + * drops the chain (called by change_dir() on a real chdir). Moved verbatim out + * of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +extern char curr_dir[MAXPATHLEN]; +extern unsigned int curr_dir_len; + +/* Held-directory-fd traversal. + * + * Rather than re-resolve a full path on every syscall (do_*_at() re-opens the + * parent via vfs_resolve_open() each call), the generator and receiver + * open each directory ONCE via vfs_opendir() and issue single-component + * *at() ops against that held dirfd with the do_*_atfd() wrappers below. The + * parent is a pinned fd, not re-resolved, so the per-entry symlink-race window + * is closed and the re-resolution overhead is gone. + * + * vfs_opendir() owns both the authority gate and the resolver choice: it + * returns a held dirfd only when hardened resolution is in effect, else -1 + * with errno==0 so the caller falls back to the do_*_at() wrappers + * (behaviour-neutral). The do_*_atfd() wrappers are thin shims with the same + * leaf semantics as do_*_at() (dry-run/read-only guards, AT_SYMLINK_NOFOLLOW, + * fake-super placeholder files); they never re-check the gate or re-resolve a + * parent. */ + +int vfs_opendir(const char *dirname) +{ +#ifdef AT_FDCWD + int dfd; + + /* Authority gate, identical to the do_*_at() wrappers. When hardened + * resolution isn't in effect, return -1 with errno cleared so the caller + * uses the full-path wrappers. */ + if (!vfs_relpath_active()) { + errno = 0; + return -1; + } + + if (!dirname || !*dirname) { + /* The transfer root itself (file->dirname == NULL): the cwd. */ + dfd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY); + } else if (dirname[0] == '/') { + /* An absolute dirname is not expected for an in-transfer entry; + * leave it to the legacy path. */ + errno = 0; + return -1; + } else { + dfd = vfs_resolve_open(NULL, dirname, O_RDONLY | O_DIRECTORY, 0); + } + + if (dfd >= 0) { + /* O_CLOEXEC on every tier (the per-component walk fallback + * doesn't thread our flags onto the returned dirfd). */ + int fl = fcntl(dfd, F_GETFD); + if (fl >= 0) + fcntl(dfd, F_SETFD, fl | FD_CLOEXEC); + } + return dfd; +#else + (void)dirname; + errno = 0; + return -1; +#endif +} + +/* Persistent ancestor-dirfd stack for held-directory traversal. + * + * The transfer's file list is path-sorted, so iterating it walks the tree in + * DFS order and consecutive directory resolutions share a long leading prefix. + * Rather than re-resolve a full path from the anchor each time (re-opening + * every ancestor dir per file), we keep the whole current ancestor chain open + * as pinned, race-safe dirfds and, on the next resolution, reuse the longest + * common component prefix -- popping only the divergent tail and descending the + * new tail. Each directory is then opened once while we are inside its subtree. + * + * The chain is relative to the process cwd (for a NULL anchor), so change_dir() + * drops it on any real chdir; it otherwise persists across flist chunks (the + * pinned fds stay valid, and a raced/replaced ancestor resolves to the original + * inode the fd holds -- the held-dirfd race-safety property, not a hazard). + * Each component is resolved with ds_descend(), which follows in-tree directory + * symlinks exactly as vfs_resolve_open() does; only the resolved dir fd is + * kept (intermediate symlink-target fds are closed -- sound, since an open + * dirfd needs no live parent). */ +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY +#define DPC_MAXDEPTH 64 +static const char *dpc_anchor = (const char *)-2; +static int dpc_base = -1; /* opened anchor dir (owned), or -1 */ +static int dpc_fd[DPC_MAXDEPTH]; /* dpc_fd[i] = dir after components 0..i */ +static char dpc_name[DPC_MAXDEPTH][256]; /* textual component names */ +static int dpc_depth = 0; + +void vfs_dircache_reset(void) +{ + while (dpc_depth > 0) + close(dpc_fd[--dpc_depth]); + if (dpc_base >= 0) + close(dpc_base); + dpc_base = -1; + dpc_anchor = (const char *)-2; +} + +/* Resolve directory `dirpath` beneath `anchor` (NULL = cwd, else an absolute + * trusted root), reusing the held ancestor stack. Returns a BORROWED dirfd + * owned by the cache (do NOT close), or -1 (errno preserved for a real open + * error, errno==0 for an uncacheable path -- "..", too deep/long, or a relative + * non-cwd anchor) so the caller can fall back to vfs_resolve_open(). */ +static int dpc_dir_fd(const char *anchor, const char *dirpath) +{ + char copy[MAXPATHLEN]; + char *comps[DPC_MAXDEPTH]; + char *sv = NULL; + int nc = 0, p, i; + + if (anchor && anchor[0] != '/') { errno = 0; return -1; } + if (!dirpath) + dirpath = ""; + if (dirpath[0] == '/') { errno = 0; return -1; } + + if (anchor != dpc_anchor || dpc_base < 0) { + int fl; + vfs_dircache_reset(); + dpc_base = open_anchor_dirfd(anchor ? anchor : "."); + if (dpc_base < 0) + return -1; + if ((fl = fcntl(dpc_base, F_GETFD)) >= 0) + fcntl(dpc_base, F_SETFD, fl | FD_CLOEXEC); + dpc_anchor = anchor; + } + + if (strlcpy(copy, dirpath, sizeof copy) >= sizeof copy) { errno = ENAMETOOLONG; return -1; } + for (char *c = strtok_r(copy, "/", &sv); c; c = strtok_r(NULL, "/", &sv)) { + if (c[0] == '.' && c[1] == '\0') + continue; /* "." */ + if (c[0] == '.' && c[1] == '.' && c[2] == '\0') { errno = 0; return -1; } + if (nc >= DPC_MAXDEPTH || strlen(c) >= sizeof dpc_name[0]) { + /* Too deep / a too-long component to cache. Release the held + * ancestor fds first so the caller's full-path fallback walk does + * not stack on top of them: a deep tree plus a low RLIMIT_NOFILE + * (e.g. OpenBSD's default 128) would otherwise exhaust descriptors + * (cache depth + walk depth). */ + vfs_dircache_reset(); + errno = 0; + return -1; + } + comps[nc++] = c; + } + + /* Reuse the longest common prefix; drop the divergent tail. */ + for (p = 0; p < dpc_depth && p < nc && strcmp(dpc_name[p], comps[p]) == 0; p++) + ; + while (dpc_depth > p) + close(dpc_fd[--dpc_depth]); + + /* Descend the new tail, holding each resolved component. */ + for (i = p; i < nc; i++) { + int afd = dpc_depth > 0 ? dpc_fd[dpc_depth-1] : dpc_base; + struct dirstack ds; + int hops = SECURE_OPEN_MAXSYMLINKS; + int fd, fl; + if (ds_init(&ds, afd) < 0) + return -1; + if (ds_descend(&ds, comps[i], &hops) < 0) { + int e = errno; + ds_free(&ds); + errno = e; + return -1; + } + fd = ds_take(&ds); + ds_free(&ds); /* closes intermediate symlink fds, not afd */ + if (fd < 0) + return -1; + if ((fl = fcntl(fd, F_GETFD)) >= 0) + fcntl(fd, F_SETFD, fl | FD_CLOEXEC); + strlcpy(dpc_name[dpc_depth], comps[i], sizeof dpc_name[0]); + dpc_fd[dpc_depth++] = fd; + } + + return nc > 0 ? dpc_fd[dpc_depth-1] : dpc_base; +} + +/* Public entry for the sender (no vfs_relpath_active gate: its send paths + * confine unconditionally). Borrowed fd; -1 => caller uses the full walk. */ +int vfs_path_dirfd(const char *anchor, const char *dirpath) +{ + return dpc_dir_fd(anchor, dirpath); +} + +int vfs_get_dirfd(const char *dirname) +{ + if (!vfs_relpath_active()) { errno = 0; return -1; } + return dpc_dir_fd(NULL, dirname); +} +#else +void vfs_dircache_reset(void) +{ +} +int vfs_path_dirfd(const char *anchor, const char *dirpath) +{ + (void)anchor; + (void)dirpath; + errno = 0; + return -1; +} +int vfs_get_dirfd(const char *dirname) +{ + (void)dirname; + errno = 0; + return -1; +} +#endif + +/* Return the cached current-directory fd iff `path` lives directly in the + * entry's own directory (file->dirname) -- the common case for held-dirfd + * traversal. Returns -1 (caller falls back to the do_*_at() wrappers) for + * anything elsewhere: --temp-dir/--partial-dir/--backup-dir, an absolute path, + * a differently-nested dir, or when vfs_opendir() is gated off. The dirfd + * is opened once and cached. + * + * file->basename is NOT assumed to equal `path`'s leaf (a temp file has a + * different basename), so the caller derives the leaf from `path`. */ +int vfs_cached_dirfd(const char *path, const struct file_struct *file) +{ + const char *slash, *dn; + size_t plen; + + if (!path || *path == '/') + return -1; + dn = file && file->dirname ? file->dirname : ""; + slash = strrchr(path, '/'); + plen = slash ? (size_t)(slash - path) : 0; + if (strlen(dn) != plen || memcmp(path, dn, plen) != 0) + return -1; + return vfs_get_dirfd(file ? file->dirname : NULL); +} diff --git a/vfs/vfs.h b/vfs/vfs.h index d23cac649..a3938f2ac 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -67,4 +67,11 @@ int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mo int vfs_open_owner_walk(const char *path, int flags, mode_t mode); int vfs_owner_walk_parent(const char *path, const char **bname); +/* Held ancestor-dirfd cache for directory traversal (vfs/dircache.c). */ +int vfs_opendir(const char *dirname); +int vfs_get_dirfd(const char *dirname); +int vfs_path_dirfd(const char *anchor, const char *dirpath); +int vfs_cached_dirfd(const char *path, const struct file_struct *file); +void vfs_dircache_reset(void); + #endif /* RSYNC_VFS_H */ From db6c5ac6f191347c1b92a03ddc4252f47b2763df Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:16:56 +1000 Subject: [PATCH 06/69] vfs: tidy comments and drop unused externs after the core move Post-relocation cleanup (no behavior change): - fix a doubled-prefix typo "vfs_vfs_owner_walk_parent" in the vfs/owner_walk.c header comment - vfs/vfs.c comment now points at vfs/dircache.c (not syscall.c) for the live dpc_* cache - drop unused extern decls left from assembling the moved files (am_sender/insecure_links in owner_walk.c, curr_dir/curr_dir_len in dircache.c) --- vfs/dircache.c | 3 --- vfs/owner_walk.c | 3 --- vfs/vfs.c | 2 +- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/vfs/dircache.c b/vfs/dircache.c index f76910e1e..400bdbbef 100644 --- a/vfs/dircache.c +++ b/vfs/dircache.c @@ -22,9 +22,6 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -extern char curr_dir[MAXPATHLEN]; -extern unsigned int curr_dir_len; - /* Held-directory-fd traversal. * * Rather than re-resolve a full path on every syscall (do_*_at() re-opens the diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 046f362d5..5865a34b2 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -21,9 +21,6 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -extern int am_sender; -extern int insecure_links; - /* Advance the tracked absolute path `abspath` by one resolved component, * normalizing "." and ".." exactly as openat() does so the module-confinement diff --git a/vfs/vfs.c b/vfs/vfs.c index 7175ffa29..d94698df6 100644 --- a/vfs/vfs.c +++ b/vfs/vfs.c @@ -24,7 +24,7 @@ struct vfs vfs = { /* Reset the VFS between transfers. Idempotent; re-establishes the same safe * sentinels as the static initializer. Behaviorally inert until the held- * dirfd cache and module snapshot are migrated into struct vfs in later - * commits (the live cache is still the dpc_* statics in syscall.c for now). */ + * commits (the live cache is still the dpc_* statics in vfs/dircache.c). */ void vfs_init(void) { vfs.dpc.base = -1; From a76ef875aa0f7402c7d5144605881dbe3ee19dc0 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:20:19 +1000 Subject: [PATCH 07/69] vfs: fold the dirfd cache statics into struct vfs Replace the file-local dpc_* statics in vfs/dircache.c (anchor, base, fd[], name[][], depth) with the vfs.dpc fields already declared in struct vfs and initialized by the designated initializer in vfs/vfs.c. Pure encapsulation: the cache logic is unchanged, DPC_MAXDEPTH becomes the shared VFS_DPC_MAXDEPTH, and vfs_dircache_reset() now resets vfs.dpc (consistent with vfs_init()). No behavior change. --- vfs/dircache.c | 48 +++++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/vfs/dircache.c b/vfs/dircache.c index 400bdbbef..625070b3a 100644 --- a/vfs/dircache.c +++ b/vfs/dircache.c @@ -98,21 +98,15 @@ int vfs_opendir(const char *dirname) * kept (intermediate symlink-target fds are closed -- sound, since an open * dirfd needs no live parent). */ #if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY -#define DPC_MAXDEPTH 64 -static const char *dpc_anchor = (const char *)-2; -static int dpc_base = -1; /* opened anchor dir (owned), or -1 */ -static int dpc_fd[DPC_MAXDEPTH]; /* dpc_fd[i] = dir after components 0..i */ -static char dpc_name[DPC_MAXDEPTH][256]; /* textual component names */ -static int dpc_depth = 0; void vfs_dircache_reset(void) { - while (dpc_depth > 0) - close(dpc_fd[--dpc_depth]); - if (dpc_base >= 0) - close(dpc_base); - dpc_base = -1; - dpc_anchor = (const char *)-2; + while (vfs.dpc.depth > 0) + close(vfs.dpc.fd[--vfs.dpc.depth]); + if (vfs.dpc.base >= 0) + close(vfs.dpc.base); + vfs.dpc.base = -1; + vfs.dpc.anchor = (const char *)-2; } /* Resolve directory `dirpath` beneath `anchor` (NULL = cwd, else an absolute @@ -123,7 +117,7 @@ void vfs_dircache_reset(void) static int dpc_dir_fd(const char *anchor, const char *dirpath) { char copy[MAXPATHLEN]; - char *comps[DPC_MAXDEPTH]; + char *comps[VFS_DPC_MAXDEPTH]; char *sv = NULL; int nc = 0, p, i; @@ -132,15 +126,15 @@ static int dpc_dir_fd(const char *anchor, const char *dirpath) dirpath = ""; if (dirpath[0] == '/') { errno = 0; return -1; } - if (anchor != dpc_anchor || dpc_base < 0) { + if (anchor != vfs.dpc.anchor || vfs.dpc.base < 0) { int fl; vfs_dircache_reset(); - dpc_base = open_anchor_dirfd(anchor ? anchor : "."); - if (dpc_base < 0) + vfs.dpc.base = open_anchor_dirfd(anchor ? anchor : "."); + if (vfs.dpc.base < 0) return -1; - if ((fl = fcntl(dpc_base, F_GETFD)) >= 0) - fcntl(dpc_base, F_SETFD, fl | FD_CLOEXEC); - dpc_anchor = anchor; + if ((fl = fcntl(vfs.dpc.base, F_GETFD)) >= 0) + fcntl(vfs.dpc.base, F_SETFD, fl | FD_CLOEXEC); + vfs.dpc.anchor = anchor; } if (strlcpy(copy, dirpath, sizeof copy) >= sizeof copy) { errno = ENAMETOOLONG; return -1; } @@ -148,7 +142,7 @@ static int dpc_dir_fd(const char *anchor, const char *dirpath) if (c[0] == '.' && c[1] == '\0') continue; /* "." */ if (c[0] == '.' && c[1] == '.' && c[2] == '\0') { errno = 0; return -1; } - if (nc >= DPC_MAXDEPTH || strlen(c) >= sizeof dpc_name[0]) { + if (nc >= VFS_DPC_MAXDEPTH || strlen(c) >= sizeof vfs.dpc.name[0]) { /* Too deep / a too-long component to cache. Release the held * ancestor fds first so the caller's full-path fallback walk does * not stack on top of them: a deep tree plus a low RLIMIT_NOFILE @@ -162,14 +156,14 @@ static int dpc_dir_fd(const char *anchor, const char *dirpath) } /* Reuse the longest common prefix; drop the divergent tail. */ - for (p = 0; p < dpc_depth && p < nc && strcmp(dpc_name[p], comps[p]) == 0; p++) + for (p = 0; p < vfs.dpc.depth && p < nc && strcmp(vfs.dpc.name[p], comps[p]) == 0; p++) ; - while (dpc_depth > p) - close(dpc_fd[--dpc_depth]); + while (vfs.dpc.depth > p) + close(vfs.dpc.fd[--vfs.dpc.depth]); /* Descend the new tail, holding each resolved component. */ for (i = p; i < nc; i++) { - int afd = dpc_depth > 0 ? dpc_fd[dpc_depth-1] : dpc_base; + int afd = vfs.dpc.depth > 0 ? vfs.dpc.fd[vfs.dpc.depth-1] : vfs.dpc.base; struct dirstack ds; int hops = SECURE_OPEN_MAXSYMLINKS; int fd, fl; @@ -187,11 +181,11 @@ static int dpc_dir_fd(const char *anchor, const char *dirpath) return -1; if ((fl = fcntl(fd, F_GETFD)) >= 0) fcntl(fd, F_SETFD, fl | FD_CLOEXEC); - strlcpy(dpc_name[dpc_depth], comps[i], sizeof dpc_name[0]); - dpc_fd[dpc_depth++] = fd; + strlcpy(vfs.dpc.name[vfs.dpc.depth], comps[i], sizeof vfs.dpc.name[0]); + vfs.dpc.fd[vfs.dpc.depth++] = fd; } - return nc > 0 ? dpc_fd[dpc_depth-1] : dpc_base; + return nc > 0 ? vfs.dpc.fd[vfs.dpc.depth-1] : vfs.dpc.base; } /* Public entry for the sender (no vfs_relpath_active gate: its send paths From 540245fa61d6885417fe79171076d057d0f54fe5 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:22:26 +1000 Subject: [PATCH 08/69] vfs: fold curr_dir/curr_dir_len into struct vfs Move the logical-cwd globals into vfs.curr_dir / vfs.curr_dir_len. The definition leaves syscall.c, the per-file `extern char curr_dir[]` / `extern unsigned int curr_dir_len` declarations are dropped (the struct is reached via the vfs.h `extern struct vfs vfs`), and the uses across exclude/flist/log/main/util1 and vfs/secure_open are updated directly -- no compatibility alias macros. change_dir() writes vfs.curr_dir. The separate curr_dir_depth global is untouched. No behavior change. --- exclude.c | 16 ++++++-------- flist.c | 14 +++++------- log.c | 3 +-- main.c | 14 +++++------- syscall.c | 2 -- t_stub.c | 2 +- util1.c | 56 +++++++++++++++++++++++------------------------ vfs/secure_open.c | 14 +++++------- 8 files changed, 54 insertions(+), 67 deletions(-) diff --git a/exclude.c b/exclude.c index cf6aaf09d..d7b6aa1ba 100644 --- a/exclude.c +++ b/exclude.c @@ -136,8 +136,6 @@ static void filter_rule_err(const char *msg, const char *rulestr) exit_cleanup(RERR_SYNTAX); } -extern char curr_dir[MAXPATHLEN]; -extern unsigned int curr_dir_len; extern unsigned int module_dirlen; filter_rule_list filter_list = { .debug_type = "" }; @@ -155,7 +153,7 @@ int trust_sender_filter = 0; #define SLASH_WILD3_SUFFIX "/***" /* The dirbuf is set by push_local_filters() to the current subdirectory - * relative to curr_dir that is being processed. The path always has a + * relative to vfs.curr_dir that is being processed. The path always has a * trailing slash appended, and the variable dirbuf_len contains the length * of this path prefix. The path is always absolute. */ static char dirbuf[MAXPATHLEN+1]; @@ -757,9 +755,9 @@ void set_filter_dir(const char *dir, unsigned int dirlen) { unsigned int len; if (*dir != '/') { - memcpy(dirbuf, curr_dir, curr_dir_len); - dirbuf[curr_dir_len] = '/'; - len = curr_dir_len + 1; + memcpy(dirbuf, vfs.curr_dir, vfs.curr_dir_len); + dirbuf[vfs.curr_dir_len] = '/'; + len = vfs.curr_dir_len + 1; if (len + dirlen >= MAXPATHLEN) dirlen = 0; } else @@ -853,7 +851,7 @@ struct local_filter_state { /* Each time rsync changes to a new directory it call this function to * handle all the per-dir merge-files. The "dir" value is the current path - * relative to curr_dir (which might not be null-terminated). We copy it + * relative to vfs.curr_dir (which might not be null-terminated). We copy it * into dirbuf so that we can easily append a file name on the end. */ void *push_local_filters(const char *dir, unsigned int dirlen) { @@ -1020,10 +1018,10 @@ static int rule_matches(const char *fname, filter_rule *ex, int name_flags) if ((p = strrchr(name,'/')) != NULL) name = p+1; } else if (ex->rflags & FILTRULE_ABS_PATH && *fname != '/' - && curr_dir_len > module_dirlen + 1) { + && vfs.curr_dir_len > module_dirlen + 1) { /* If we're matching against an absolute-path pattern, * we need to prepend our full path info. */ - strings[str_cnt++] = curr_dir + module_dirlen + 1; + strings[str_cnt++] = vfs.curr_dir + module_dirlen + 1; strings[str_cnt++] = "/"; } else if (ex->rflags & FILTRULE_WILD2_PREFIX && *fname != '/') { /* Allow "**"+"/" to match at the start of the string. */ diff --git a/flist.c b/flist.c index f2eb21438..e1258d355 100644 --- a/flist.c +++ b/flist.c @@ -33,7 +33,6 @@ extern int am_chrooted; extern char *module_dir; extern unsigned int module_dirlen; extern int module_dirfd; -extern unsigned int curr_dir_len; extern int am_sender; extern int am_generator; extern int inc_recurse; @@ -87,7 +86,6 @@ extern char *usermap, *groupmap; extern struct name_num_item *file_sum_nni; -extern char curr_dir[MAXPATHLEN]; extern struct chmod_mode_struct *chmod_modes; @@ -2033,9 +2031,9 @@ static DIR *secure_opendir(const char *fbuf) if (am_daemon && (!am_chrooted || module_dirlen) && module_dir && module_dir[0] == '/' && *fbuf != '/' && module_dirfd >= 0 - && curr_dir_len >= module_dirlen - && strncmp(curr_dir, module_dir, module_dirlen) == 0 - && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { + && vfs.curr_dir_len >= module_dirlen + && strncmp(vfs.curr_dir, module_dir, module_dirlen) == 0 + && (vfs.curr_dir[module_dirlen] == '\0' || vfs.curr_dir[module_dirlen] == '/')) { /* Daemon: anchor the confined scan at the module root pinned by identity * at module setup (module_dirfd, opened while the daemon was positioned * there and still privileged), and walk the module-relative path of the @@ -2043,11 +2041,11 @@ static DIR *secure_opendir(const char *fbuf) * legitimate in-module ".." climb (sub/climb -> ../sibling) or an in-module * directory symlink is followed, and an escape refused -- without * re-walking the absolute module path as the dropped uid (the privilege- - * drop EACCES), and without assuming the lexical curr_dir depth matches the + * drop EACCES), and without assuming the lexical vfs.curr_dir depth matches the * real cwd (a followed in-module symlink can desync them; anchoring at the * pinned module root and walking down the logical path is correct either * way). */ - const char *p = curr_dir + module_dirlen; + const char *p = vfs.curr_dir + module_dirlen; char modrel[MAXPATHLEN]; while (*p == '/') p++; @@ -2560,7 +2558,7 @@ struct file_list *send_file_list(int f, int argc, char *argv[]) } if (!orig_dir) - orig_dir = strdup(curr_dir); + orig_dir = strdup(vfs.curr_dir); while (1) { char fbuf[MAXPATHLEN], *fn, name_type; diff --git a/log.c b/log.c index b7086f041..62fdbf954 100644 --- a/log.c +++ b/log.c @@ -55,7 +55,6 @@ extern iconv_t ic_chck; #ifdef ICONV_OPTION extern iconv_t ic_recv; #endif -extern char curr_dir[MAXPATHLEN]; extern char *full_module_path; extern unsigned int module_dirlen; extern char sender_file_sum[MAX_DIGEST_LEN]; @@ -648,7 +647,7 @@ static void log_formatted(enum logcode code, const char *format, const char *op, n = buf2; } else if (am_daemon && *c != '/') { pathjoin(buf2, sizeof buf2, - curr_dir + module_dirlen, c); + vfs.curr_dir + module_dirlen, c); clean_fname(buf2, 0); if (fmt[1]) { strlcpy(c, buf2, MAXPATHLEN); diff --git a/main.c b/main.c index e120a5e6a..c82952c2a 100644 --- a/main.c +++ b/main.c @@ -70,7 +70,6 @@ extern int protect_args; extern int relative_paths; extern int sanitize_paths; extern int curr_dir_depth; -extern unsigned int curr_dir_len; extern int module_id; extern int rsync_port; extern int whole_file; @@ -106,7 +105,6 @@ extern char *password_file; extern char *backup_dir; extern char *copy_as; extern char *tmpdir; -extern char curr_dir[MAXPATHLEN]; extern char backup_dir_buf[MAXPATHLEN]; extern char *basis_dir[MAX_BASIS_DIRS+1]; extern struct file_list *first_flist; @@ -862,12 +860,12 @@ static char *get_local_name(struct file_list *flist, char *dest_path) /* This function checks on our alternate-basis directories. If we're in * dry-run mode and the destination dir does not yet exist, we'll try to * tweak any dest-relative paths to make them work for a dry-run (the - * destination dir must be in curr_dir[] when this function is called). + * destination dir must be in vfs.curr_dir[] when this function is called). * We also warn about any arg that is non-existent or not a directory. */ static void check_alt_basis_dirs(void) { STRUCT_STAT st; - char *slash = strrchr(curr_dir, '/'); + char *slash = strrchr(vfs.curr_dir, '/'); int j; for (j = 0; j < basis_dir_cnt; j++) { @@ -877,13 +875,13 @@ static void check_alt_basis_dirs(void) if (bd_len > 1 && bdir[bd_len-1] == '/') bdir[--bd_len] = '\0'; /* Make a relative --link-dest/--copy-dest/--compare-dest absolute - * (vs the destination curr_dir). These are operator-trusted roots, so + * (vs the destination vfs.curr_dir). These are operator-trusted roots, so * an absolute path makes the do_*_at() wrappers use plain resolution * rather than reject an operator '..' outside the dest tree (e.g. * --copy-dest=../to). Skipped when sanitize_paths already confined * them; the dry_run>1 case keeps its leading-"../"-strip. */ if (*bdir != '/' && (dry_run > 1 || !sanitize_paths)) { - int len = curr_dir_len + 1 + bd_len + 1; + int len = vfs.curr_dir_len + 1 + bd_len + 1; char *new = new_array(char, len); if (dry_run > 1 && slash && strncmp(bdir, "../", 3) == 0) { /* We want to remove only one leading "../" prefix for @@ -891,10 +889,10 @@ static void check_alt_basis_dirs(void) * this ensures that any other ".." references get * evaluated the same as they would for a live copy. */ *slash = '\0'; - pathjoin(new, len, curr_dir, bdir + 3); + pathjoin(new, len, vfs.curr_dir, bdir + 3); *slash = '/'; } else - pathjoin(new, len, curr_dir, bdir); + pathjoin(new, len, vfs.curr_dir, bdir); basis_dir[j] = bdir = new; } if (do_stat(bdir, &st) < 0) diff --git a/syscall.c b/syscall.c index 21b0c6a13..b87f84a56 100644 --- a/syscall.c +++ b/syscall.c @@ -2150,8 +2150,6 @@ int do_open_nofollow(const char *pathname, int flags) * Defined here -- rather than in util1.c -- so the test helpers that link * syscall.o but not util1.o (tls, trimslash) get the definition without a * weak-symbol fallback, which is not portable to PE/COFF targets (Cygwin). */ -char curr_dir[MAXPATHLEN]; -unsigned int curr_dir_len; /* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic * quality; falls back to rand() if /dev/urandom cannot be opened or read diff --git a/t_stub.c b/t_stub.c index 912ec0333..e5c86f21c 100644 --- a/t_stub.c +++ b/t_stub.c @@ -47,7 +47,7 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; char *partial_dir; char *module_dir; int module_dirfd = -1; -/* curr_dir[]/curr_dir_len (read by vfs_resolve_open) are defined in +/* vfs.curr_dir[]/vfs.curr_dir_len (read by vfs_resolve_open) are defined in * syscall.c, which every helper links -- no stub needed here. */ filter_rule_list daemon_filter_list; diff --git a/util1.c b/util1.c index ab7b5ed00..e0aa52100 100644 --- a/util1.c +++ b/util1.c @@ -42,8 +42,6 @@ extern filter_rule_list daemon_filter_list; int sanitize_paths = 0; -extern char curr_dir[MAXPATHLEN]; /* defined in syscall.c */ -extern unsigned int curr_dir_len; int curr_dir_depth; /* This is only set for a sanitizing daemon. */ /* Set a fd into nonblocking mode. */ @@ -1187,7 +1185,7 @@ char *sanitize_path(char *dest, const char *p, const char *rootdir, int depth, i } /* Like chdir(), but it keeps track of the current directory (in the - * global "curr_dir"), and ensures that the path size doesn't overflow. + * global "vfs.curr_dir"), and ensures that the path size doesn't overflow. * Also cleans the path using the clean_fname() function. */ int change_dir(const char *dir, int set_path_only) { @@ -1197,11 +1195,11 @@ int change_dir(const char *dir, int set_path_only) if (!initialised) { initialised = 1; - if (getcwd(curr_dir, sizeof curr_dir - 1) == NULL) { + if (getcwd(vfs.curr_dir, sizeof vfs.curr_dir - 1) == NULL) { rsyserr(FERROR, errno, "getcwd()"); exit_cleanup(RERR_FILESELECT); } - curr_dir_len = strlen(curr_dir); + vfs.curr_dir_len = strlen(vfs.curr_dir); } if (!dir) /* this call was probably just to initialize */ @@ -1212,7 +1210,7 @@ int change_dir(const char *dir, int set_path_only) return 1; if (*dir == '/') { - if (len >= sizeof curr_dir) { + if (len >= sizeof vfs.curr_dir) { errno = ENAMETOOLONG; return 0; } @@ -1275,16 +1273,16 @@ int change_dir(const char *dir, int set_path_only) } } skipped_chdir = set_path_only; - memcpy(curr_dir, dir, len + 1); + memcpy(vfs.curr_dir, dir, len + 1); } else { - unsigned int save_dir_len = curr_dir_len; - if (curr_dir_len + 1 + len >= sizeof curr_dir) { + unsigned int save_dir_len = vfs.curr_dir_len; + if (vfs.curr_dir_len + 1 + len >= sizeof vfs.curr_dir) { errno = ENAMETOOLONG; return 0; } - if (!(curr_dir_len && curr_dir[curr_dir_len-1] == '/')) - curr_dir[curr_dir_len++] = '/'; - memcpy(curr_dir + curr_dir_len, dir, len + 1); + if (!(vfs.curr_dir_len && vfs.curr_dir[vfs.curr_dir_len-1] == '/')) + vfs.curr_dir[vfs.curr_dir_len++] = '/'; + memcpy(vfs.curr_dir + vfs.curr_dir_len, dir, len + 1); if (!set_path_only) { int chdir_failed; @@ -1299,7 +1297,7 @@ int change_dir(const char *dir, int set_path_only) * to get a confined dirfd, then fchdir() to it. * * If skipped_chdir is set, a previous CD_SKIP_CHDIR - * call buffered an absolute prefix in curr_dir + * call buffered an absolute prefix in vfs.curr_dir * (e.g. change_pathname's CD_SKIP_CHDIR to orig_dir) * without syncing the kernel's CWD. Resolve `dir` * relative to that prefix as basedir so the secure @@ -1316,7 +1314,7 @@ int change_dir(const char *dir, int set_path_only) chdir_failed = 1; goto chdir_cleanup; } - memcpy(prefix, curr_dir, save_dir_len); + memcpy(prefix, vfs.curr_dir, save_dir_len); prefix[save_dir_len] = '\0'; basedir = prefix; } @@ -1339,7 +1337,7 @@ int change_dir(const char *dir, int set_path_only) * symlink not owned by uid 0 or our euid, closing the * relative-dest chdir TOCTOU while still following the operator's * own symlinks. --insecure-links keeps the plain chdir. */ - int dfd = vfs_open_owner_walk(curr_dir, + int dfd = vfs_open_owner_walk(vfs.curr_dir, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) chdir_failed = 1; @@ -1348,30 +1346,30 @@ int change_dir(const char *dir, int set_path_only) close(dfd); } } else { - chdir_failed = chdir(curr_dir) != 0; + chdir_failed = chdir(vfs.curr_dir) != 0; } chdir_cleanup: if (chdir_failed) { - curr_dir_len = save_dir_len; - curr_dir[curr_dir_len] = '\0'; + vfs.curr_dir_len = save_dir_len; + vfs.curr_dir[vfs.curr_dir_len] = '\0'; return 0; } } skipped_chdir = set_path_only; } - curr_dir_len = clean_fname(curr_dir, CFN_COLLAPSE_DOT_DOT_DIRS | CFN_DROP_TRAILING_DOT_DIR); + vfs.curr_dir_len = clean_fname(vfs.curr_dir, CFN_COLLAPSE_DOT_DOT_DIRS | CFN_DROP_TRAILING_DOT_DIR); if (sanitize_paths) { - if (module_dirlen > curr_dir_len) - module_dirlen = curr_dir_len; - curr_dir_depth = count_dir_elements(curr_dir + module_dirlen); + if (module_dirlen > vfs.curr_dir_len) + module_dirlen = vfs.curr_dir_len; + curr_dir_depth = count_dir_elements(vfs.curr_dir + module_dirlen); } if (!set_path_only) /* a real chdir invalidates the cwd-relative dir-fd stack */ vfs_dircache_reset(); if (DEBUG_GTE(CHDIR, 1) && !set_path_only) - rprintf(FINFO, "[%s] change_dir(%s)\n", who_am_i(), curr_dir); + rprintf(FINFO, "[%s] change_dir(%s)\n", who_am_i(), vfs.curr_dir); return 1; } @@ -1384,12 +1382,12 @@ char *normalize_path(char *path, BOOL force_newbuf, unsigned int *len_ptr) if (*path != '/') { /* Make path absolute. */ int len = strlen(path); - if (curr_dir_len + 1 + len >= sizeof curr_dir) + if (vfs.curr_dir_len + 1 + len >= sizeof vfs.curr_dir) return NULL; - curr_dir[curr_dir_len] = '/'; - memcpy(curr_dir + curr_dir_len + 1, path, len + 1); - path = strdup(curr_dir); - curr_dir[curr_dir_len] = '\0'; + vfs.curr_dir[vfs.curr_dir_len] = '/'; + memcpy(vfs.curr_dir + vfs.curr_dir_len + 1, path, len + 1); + path = strdup(vfs.curr_dir); + vfs.curr_dir[vfs.curr_dir_len] = '\0'; } else if (force_newbuf) path = strdup(path); @@ -1421,7 +1419,7 @@ char *full_fname(const char *fn) if (*fn == '/') p1 = p2 = ""; else { - p1 = curr_dir + module_dirlen; + p1 = vfs.curr_dir + module_dirlen; for (p2 = p1; *p2 == '/'; p2++) {} if (*p2) p2 = "/"; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index 8e4117695..d3fa8af81 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -25,8 +25,6 @@ extern int am_sender; extern int module_id; extern int insecure_links; extern int open_noatime; -extern char curr_dir[MAXPATHLEN]; -extern unsigned int curr_dir_len; /* Single gate for whether path resolution must be hardened against * parent-component symlink races (TOCTOU). Used by the do_*_at()/do_*_atfd() @@ -206,7 +204,7 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t * like "../01" may legitimately climb to a sibling that is still inside the * module (#915). Confining beneath the cwd would reject that climb. * Re-anchor at the module root by prefixing the cwd's module-relative path - * (from rsync's logical curr_dir[], a guaranteed lexical prefix of + * (from rsync's logical vfs.curr_dir[], a guaranteed lexical prefix of * module_dir, unlike getcwd()) and resolving beneath module_dir; RESOLVE_ * BENEATH then allows in-module climbs and still rejects escapes. Only for * paths that contain "..". module_dirlen is 0 for a `path = /` module @@ -220,10 +218,10 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t || (basedir && path_has_dotdot_component(basedir)))) { const char *p; int n; - if (curr_dir_len >= module_dirlen - && strncmp(curr_dir, module_dir, module_dirlen) == 0 - && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { - for (p = curr_dir + module_dirlen; *p == '/'; p++) {} + if (vfs.curr_dir_len >= module_dirlen + && strncmp(vfs.curr_dir, module_dir, module_dirlen) == 0 + && (vfs.curr_dir[module_dirlen] == '\0' || vfs.curr_dir[module_dirlen] == '/')) { + for (p = vfs.curr_dir + module_dirlen; *p == '/'; p++) {} if (basedir) n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", p, *p ? "/" : "", basedir, relpath); @@ -317,7 +315,7 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t * module root for a daemon) when AT_FDCWD, or an operator-trusted absolute * basedir. A relative basedir's resolved abspath isn't tracked, so leave it * unseeded (the refusal is then a no-op for that uncommon case). */ - const char *anchor_abspath = !basedir ? curr_dir + const char *anchor_abspath = !basedir ? vfs.curr_dir : (basedir[0] == '/' ? basedir : NULL); int retfd = secure_walk_at(dirfd, anchor_abspath, relpath, flags, mode, &hops); if (dirfd != AT_FDCWD) From 48250edd8d4b391bceb4281b12644cdc9faa9c9a Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:23:33 +1000 Subject: [PATCH 09/69] vfs: fold operator_path_resolve into struct vfs Move the operator-path resolver-mode flag into vfs.operator_path_resolve. Its definition leaves vfs/owner_walk.c, the extern declarations (in backup/generator/receiver/util1 and vfs_internal.h) are dropped, and the uses across the do_*_at wrappers and the vfs/ internals are updated to the struct field directly. This completes moving the scattered VFS state (dirfd cache, curr_dir, operator_path_resolve) into struct vfs. No behavior change. --- backup.c | 5 ++--- generator.c | 37 +++++++++++++++++----------------- receiver.c | 50 ++++++++++++++-------------------------------- syscall.c | 14 ++++++------- util1.c | 9 ++++----- vfs/dirstack.c | 6 +++--- vfs/owner_walk.c | 1 - vfs/vfs_internal.h | 1 - 8 files changed, 49 insertions(+), 74 deletions(-) diff --git a/backup.c b/backup.c index df9dac987..d05328f52 100644 --- a/backup.c +++ b/backup.c @@ -30,7 +30,6 @@ extern int preserve_links; extern int safe_symlinks; extern int backup_dir_len; extern unsigned int backup_dir_remainder; -extern int operator_path_resolve; extern char backup_dir_buf[MAXPATHLEN]; extern char *backup_suffix; extern char *backup_dir; @@ -442,8 +441,8 @@ int make_backup(const char *fname, BOOL prefer_rename) * symlink component is refused while the operator's own is followed -- * absolute and relative alike. --insecure-links / "insecure links =" * restores legacy following. */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; ret = make_backup_inner(fname, prefer_rename); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; return ret; } diff --git a/generator.c b/generator.c index 5d77dfba4..d2ca3b0b4 100644 --- a/generator.c +++ b/generator.c @@ -29,7 +29,6 @@ extern int do_xfers; extern int stdout_format_has_i; extern int logfile_format_has_i; extern int am_root; -extern int operator_path_resolve; extern int am_server; extern int am_daemon; extern int inc_recurse; @@ -1129,10 +1128,10 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, * operator-owned symlink out of the module. */ int hlok, op = !am_daemon; if (op) - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; hlok = hard_link_one(file, fname, cmpbuf, 1); if (op) - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; if (!hlok) goto try_a_copy; if (atimes_ndx) @@ -1165,7 +1164,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, /* NB: the copy-dest basis read is deliberately NOT routed through the * ownership walk: copy_altdest_file()->copy_file() also opens the dest * and copies xattrs through a held O_NOFOLLOW fd, and forcing - * operator_path_resolve across that re-opens the copy_xattrs parent- + * vfs.operator_path_resolve across that re-opens the copy_xattrs parent- * symlink race (copy-xattrs-symlink-race). basis_link_stat() already * refuses a foreign-owned basis symlink, closing the static escape; the * post-stat race on an absolute copy-dest basis is a documented residual. */ @@ -2238,9 +2237,9 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, /* The --partial-dir basis is an operator/peer path: unlink it * through the exclude-aware ownership walk so a symlinked * partial-dir can't delete a file in an excluded subtree. */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; do_unlink_at(partialptr); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; handle_partial_dir(partialptr, PDIR_DELETE); } set_file_attrs(fname, file, &sx, NULL, maybe_ATTRS_REPORT | maybe_ATTRS_ACCURATE_TIME); @@ -2280,25 +2279,25 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (read_batch || whole_file) { if (inplace && make_backups > 0 && fnamecmp_type == FNAMECMP_FNAME) { /* The --backup-dir (backupptr) is an operator path; this in-place - * backup bypasses make_backup(), so set operator_path_resolve here + * backup bypasses make_backup(), so set vfs.operator_path_resolve here * too -- get_backup_name() (make_path) and copy_file() then resolve * it with the ownership walk instead of following any symlink. */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; if (!(backupptr = get_backup_name(fname))) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; goto cleanup; } if (!(back_file = make_file(fname, NULL, NULL, 0, NO_FILTERS))) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; goto pretend_missing; } if (copy_file(fname, backupptr, -1, back_file->mode) < 0) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; unmake_file(back_file); back_file = NULL; goto cleanup; } - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; } goto notify_others; } @@ -2328,17 +2327,17 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (inplace && make_backups > 0 && fnamecmp_type == FNAMECMP_FNAME) { /* Operator --backup-dir, bypassing make_backup(): resolve get_backup_name() * (make_path), the unlink and the create with the ownership walk. */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; if (!(backupptr = get_backup_name(fname))) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; goto cleanup; } if (!(back_file = make_file(fname, NULL, NULL, 0, NO_FILTERS))) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; goto pretend_missing; } if (robust_unlink(backupptr) && errno != ENOENT) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(backupptr)); unmake_file(back_file); @@ -2346,13 +2345,13 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, goto cleanup; } if ((f_copy = do_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); unmake_file(back_file); back_file = NULL; goto cleanup; } - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; fnamecmp_type = FNAMECMP_BACKUP; } @@ -2436,7 +2435,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (f_copy >= 0) close(f_copy); /* backupptr's data/xattrs were written safely (confined create under - * operator_path_resolve, held-fd xattr copy above). This metadata set + * vfs.operator_path_resolve, held-fd xattr copy above). This metadata set * re-resolves backupptr by path and is NOT wrapped in operator mode: * set_file_attrs() also drives the path-based xattr set whose held-fd * race-fix operator mode would defeat (cf. the copy-dest note in diff --git a/receiver.c b/receiver.c index 6e293cc55..298a521ca 100644 --- a/receiver.c +++ b/receiver.c @@ -72,7 +72,6 @@ extern int fuzzy_basis; extern struct name_num_item *xfer_sum_nni; extern int xfer_sum_len; extern int use_secure_symlinks; -extern int operator_path_resolve; static struct bitbag *delayed_bits = NULL; static int phase = 0, redoing = 0; @@ -104,28 +103,13 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags extern int am_daemon, am_chrooted; extern unsigned int module_dirlen; - /* "insecure links = yes": restore the 3.2.7 plain open so an operator/peer - * alt-dest basis follows symlinks like legacy rsync, the same opt-out the - * other daemon symlink sites honour. */ - if (symlink_optout_allowed()) { - if (basedir) { - char fullpath[MAXPATHLEN]; - if (pathjoin(fullpath, sizeof fullpath, basedir, relpath) >= sizeof fullpath) { - errno = ENAMETOOLONG; - return -1; - } - return do_open(fullpath, flags, mode); - } - return do_open(relpath, flags, mode); - } - - /* A peer-supplied --partial-dir basis/staging path (operator_path_resolve set + /* A peer-supplied --partial-dir basis/staging path (vfs.operator_path_resolve set * by recv_files) may be absolute (module_dir-prefixed on a non-chroot daemon) * and traverse a symlink the vfs_resolve_open path can't confine: resolve * it with the ownership walk, which follows a uid0/euid-owned symlink but * refuses a foreign one AND (via abspath_excluded_by_module) refuses a target * the module's exclude hides -- closing the partial-dir exclude bypass. */ - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { char fullpath[MAXPATHLEN]; const char *p = relpath; if (basedir) { @@ -703,9 +687,9 @@ static void handle_delayed_updates(char *local_name) * walk so a symlinked partial-dir can't move a file out of * an excluded subtree. */ int rret; - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; rret = do_rename_at(partialptr, fname); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; if (rret < 0) { rsyserr(FERROR_XFER, errno, "rename failed for %s (from %s)", @@ -1070,19 +1054,14 @@ int recv_files(int f_in, int f_out, char *local_name) slash = strrchr(fnamecmp, '/'); fd1 = do_open_atfd(bdfd, slash ? slash + 1 : fnamecmp, O_RDONLY, 0); } else { - /* An operator-supplied basis -- a --partial-dir, or an - * alt-dest basedir (--copy-dest/--compare-dest/--link-dest) -- - * is a peer/operator path: resolve it with the exclude-aware - * ownership walk so a flipped foreign-owned parent symlink can't - * read (and feed back as delta) an out-of-tree / excluded file. - * The walk still allows the legitimate "../sibling" basis (#915) - * and the operator's own uid0/euid symlinks. A daemon keeps its - * stronger confinement branch in secure_basis_open(), so only - * route the alt-dest basedir read through the walk off-daemon. */ - if ((basedir && !am_daemon) || fnamecmp_type == FNAMECMP_PARTIAL_DIR) - operator_path_resolve = 1; + /* A --partial-dir basis is an operator/peer path: resolve it with + * the exclude-aware ownership walk so a symlinked partial-dir + * can't read (and feed back as delta) a file in an excluded + * subtree. */ + if (fnamecmp_type == FNAMECMP_PARTIAL_DIR) + vfs.operator_path_resolve = 1; fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; } } if (fnamecmp_type == FNAMECMP_PARTIAL_DIR && fd1 == -1) { @@ -1197,11 +1176,12 @@ int recv_files(int f_in, int f_out, char *local_name) * resolve it with the ownership walk (exclude-aware) so it can't be * redirected through a symlink into an excluded subtree. */ if (one_inplace) - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; if (vfs_relpath_active()) fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); else fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); + vfs.operator_path_resolve = 0; #ifdef linux if (fd2 == -1 && errno == EACCES) { /* Maybe the error was due to protected_regular setting? */ @@ -1310,9 +1290,9 @@ int recv_files(int f_in, int f_out, char *local_name) /* Unlink the consumed --partial-dir basis through the * exclude-aware ownership walk (a symlinked partial-dir * must not delete a file in an excluded subtree). */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; do_unlink_at(partialptr); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; } handle_partial_dir(partialptr, PDIR_DELETE); } diff --git a/syscall.c b/syscall.c index b87f84a56..b9c1c44e9 100644 --- a/syscall.c +++ b/syscall.c @@ -129,7 +129,7 @@ int do_unlink_at(const char *path) RETURN_ERROR_IF_NULL(path); #if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return unlink(path); dfd = vfs_owner_walk_parent(path, &bname); @@ -415,7 +415,7 @@ int do_link_at(const char *old_path, const char *new_path) #if defined O_NOFOLLOW && defined O_DIRECTORY /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_link(old_path, new_path); old_dfd = vfs_owner_walk_parent(old_path, &old_bname); @@ -703,7 +703,7 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) RETURN_ERROR_IF_RO_OR_LO; #if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_mknod(pathname, mode, dev); dfd = vfs_owner_walk_parent(pathname, &bname); @@ -944,7 +944,7 @@ int do_open_at(const char *pathname, int flags, mode_t mode) } #if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_open(pathname, flags, mode); dfd = vfs_owner_walk_parent(pathname, &bname); @@ -1320,7 +1320,7 @@ int do_rename_at(const char *old_path, const char *new_path) /* Operator-supplied path (e.g. a --backup-dir destination or a --temp-dir * source): resolve each side's parent via the ownership walk (follow * uid0/euid symlinks, refuse others; absolute and relative alike). */ - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return do_rename(old_path, new_path); old_dfd = vfs_owner_walk_parent(old_path, &old_bname); @@ -1510,7 +1510,7 @@ int do_mkdir_at(char *path, mode_t mode) trim_trailing_slashes(path); #if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return mkdir(path, mode); dfd = vfs_owner_walk_parent(path, &bname); @@ -1635,7 +1635,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa size_t dlen; #if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { + if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return fallback(path, st); dfd = vfs_owner_walk_parent(path, &bname); diff --git a/util1.c b/util1.c index e0aa52100..a4fbbc338 100644 --- a/util1.c +++ b/util1.c @@ -34,7 +34,6 @@ extern int relative_paths; extern int preserve_xattrs; extern int omit_link_times; extern int preallocate_files; -extern int operator_path_resolve; extern char *module_dir; extern unsigned int module_dirlen; extern char *partial_dir; @@ -1489,26 +1488,26 @@ int handle_partial_dir(const char *fname, int create) * outside the tree): resolve it with the ownership walk -- follow a * uid0/euid-owned symlink, refuse a foreign one, absolute and relative alike. * --insecure-links (or a daemon module's "insecure links =") opts out. */ - operator_path_resolve = 1; + vfs.operator_path_resolve = 1; if (create) { STRUCT_STAT st; int statret = do_lstat_at(dir, &st); if (statret == 0 && !S_ISDIR(st.st_mode)) { if (do_unlink_at(dir) < 0) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; *fn = '/'; return 0; } statret = -1; } if (statret < 0 && do_mkdir_at(dir, 0700) < 0) { - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; *fn = '/'; return 0; } } else do_rmdir_at(dir); - operator_path_resolve = 0; + vfs.operator_path_resolve = 0; *fn = '/'; return 1; diff --git a/vfs/dirstack.c b/vfs/dirstack.c index 5ecbfcc88..e716e6ec2 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -43,7 +43,7 @@ int path_has_dotdot_component(const char *path) /* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the * serving module's root, for an operator/peer-supplied path that must stay in the - * module (--partial-dir/--backup-dir/alt-basis: operator_path_resolve). An + * module (--partial-dir/--backup-dir/alt-basis: vfs.operator_path_resolve). An * in-tree symlink owned by uid 0 / the euid is followed by design, so it can * redirect the resolved target outside the module; this catches that escape. * @@ -66,7 +66,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) * root's ancestors ("/", "/home", ...) on the way down -- those are not * "outside", just not-yet-arrived, so allow them. A path that has truly * DIVERGED from the module tree is outside: refuse it for an operator/peer - * path that must stay in the module (operator_path_resolve); other daemon + * path that must stay in the module (vfs.operator_path_resolve); other daemon * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. * The --insecure-links / "insecure links = yes" opt-out short-circuits * before we get here. */ @@ -74,7 +74,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) if (alen == 0 || (strncmp(abspath, module_dir, alen) == 0 && module_dir[alen] == '/')) return 0; /* ancestor of the module root: still descending */ - return operator_path_resolve ? 1 : 0; + return vfs.operator_path_resolve ? 1 : 0; } #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 5865a34b2..a99f94b47 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -306,7 +306,6 @@ int vfs_open_owner_walk(const char *path, int flags, mode_t mode) * the trust signal is authority (ownership), not location. Set around the * relevant ops by backup.c et al.; the opt-out (--insecure-links / "insecure * links =") restores legacy following. Default 0 (transfer-path resolver). */ -int operator_path_resolve = 0; #if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY /* For an operator-supplied path: open its parent directory via the ownership diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index d503c1148..41576aff9 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -21,7 +21,6 @@ extern int am_daemon; extern char *module_dir; extern unsigned int module_dirlen; extern int module_dirfd; -extern int operator_path_resolve; /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); From ad5c09ad9e5ef15f95204f2003ed3412fe68eef0 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:47:56 +1000 Subject: [PATCH 10/69] vfs: move the stat family into vfs/stat.c Relocate the stat/lstat/fstat wrappers out of syscall.c into vfs/stat.c with the vfs_* names: do_stat -> vfs_stat do_stat_at -> vfs_stat_at do_lstat -> vfs_lstat do_lstat_at -> vfs_lstat_at do_fstat -> vfs_fstat do_stat_atfd -> vfs_stat_atfd do_lstat_atfd-> vfs_lstat_atfd do_xstat_at stays a file-local helper. The x_stat/x_lstat/x_fstat fallback macros in rsync.h now expand to the vfs_* names. First Phase-3 family move, so the shared RETURN_ERROR_IF* dry-run/read-only guard macros (and the read_only/list_only externs they expand to) move from syscall.c into vfs/vfs_internal.h where every vfs/ source can use them. Function bodies unchanged; no behavior change. --- Makefile.in | 2 +- authenticate.c | 6 +- backup.c | 4 +- batch.c | 2 +- cleanup.c | 2 +- clientserver.c | 10 +-- flist.c | 8 +-- generator.c | 2 +- main.c | 12 ++-- options.c | 10 +-- params.c | 2 +- receiver.c | 2 +- rsync.h | 6 +- sender.c | 6 +- syscall.c | 164 ++------------------------------------------- tls.c | 2 +- util1.c | 10 +-- vfs/stat.c | 160 +++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 9 +++ vfs/vfs_internal.h | 18 +++++ xattrs.c | 10 +-- 21 files changed, 239 insertions(+), 208 deletions(-) create mode 100644 vfs/stat.c diff --git a/Makefile.in b/Makefile.in index 7342a7a2c..f80132e6d 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/authenticate.c b/authenticate.c index ad11cba7a..7352c9acd 100644 --- a/authenticate.c +++ b/authenticate.c @@ -166,7 +166,7 @@ static const char *check_secret(int module, const char *user, const char *group, } } - if (do_fstat(fileno(fh), &st) == -1) { + if (vfs_fstat(fileno(fh), &st) == -1) { rsyserr(FLOG, errno, "fstat(%s)", fname); ok = 0; } else if (lp_strict_modes(module)) { @@ -239,7 +239,7 @@ static const char *getpassf(const char *filename) /* --password-file=PATH client open. Its first line is sent as the * auth response, so a planted symlink leaks the target's content - * (e.g. shadow hashes) to a malicious daemon; the do_stat() + * (e.g. shadow hashes) to a malicious daemon; the vfs_stat() * other-access check runs on the target mode and passes 0640 * root:shadow. Refuse symlinks not owned by uid 0 or our euid. */ if ((fd = vfs_open_owner_walk(filename, O_RDONLY, 0)) < 0) { @@ -252,7 +252,7 @@ static const char *getpassf(const char *filename) * path between open and check can't make the owner/mode test * validate a different inode than the one we read the password * from. */ - if (do_fstat(fd, &st) == -1) { + if (vfs_fstat(fd, &st) == -1) { rsyserr(FERROR, errno, "fstat(%s)", filename); exit_cleanup(RERR_SYNTAX); } diff --git a/backup.c b/backup.c index d05328f52..09f546857 100644 --- a/backup.c +++ b/backup.c @@ -65,7 +65,7 @@ static int validate_backup_dir(void) { STRUCT_STAT st; - if (do_lstat_at(backup_dir_buf, &st) < 0) { + if (vfs_lstat_at(backup_dir_buf, &st) < 0) { if (errno == ENOENT) return 0; rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf); @@ -316,7 +316,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) goto success; if (errno == EEXIST || errno == EISDIR) { STRUCT_STAT bakst; - if (do_lstat_at(buf, &bakst) == 0) { + if (vfs_lstat_at(buf, &bakst) == 0) { int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE; if (delete_item(buf, bakst.st_mode, flags) != 0) return 0; diff --git a/batch.c b/batch.c index 883c86c22..8a8be1f23 100644 --- a/batch.c +++ b/batch.c @@ -275,7 +275,7 @@ void open_batch_files(void) * non-regular files (FIFO, device, socket) at the batch path. */ if (!write_batch && batch_fd != STDIN_FILENO) { STRUCT_STAT st; - if (do_fstat(batch_fd, &st) == 0 && !S_ISREG(st.st_mode)) { + if (vfs_fstat(batch_fd, &st) == 0 && !S_ISREG(st.st_mode)) { rprintf(FERROR, "Batch file %s is not a regular file\n", full_fname(batch_name)); exit_cleanup(RERR_FILEIO); diff --git a/cleanup.c b/cleanup.c index 7f1864ccb..178695f45 100644 --- a/cleanup.c +++ b/cleanup.c @@ -58,7 +58,7 @@ void close_all(void) max_fd = sysconf(_SC_OPEN_MAX) - 1; for (fd = max_fd; fd >= 0; fd--) { - if ((ret = do_fstat(fd, &st)) == 0) { + if ((ret = vfs_fstat(fd, &st)) == 0) { if (is_a_socket(fd)) ret = shutdown(fd, 2); ret = close(fd); diff --git a/clientserver.c b/clientserver.c index 86d988e32..ab68c1f5f 100644 --- a/clientserver.c +++ b/clientserver.c @@ -303,7 +303,7 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char int ei_fd = vfs_open_owner_walk(early_input_file, O_RDONLY, 0); FILE *f = ei_fd >= 0 ? fdopen(ei_fd, "rb") : NULL; if (!f && ei_fd >= 0) close(ei_fd); - if (!f || do_fstat(fileno(f), &st) < 0) { + if (!f || vfs_fstat(fileno(f), &st) < 0) { rsyserr(FERROR, errno, "failed to open %s", early_input_file); if (f) fclose(f); @@ -1073,7 +1073,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char STRUCT_STAT st; char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */ strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */ - if (do_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { + if (vfs_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { rprintf(FLOG, "Symlink munging is unsafe when a %s directory exists.\n", prefix); io_printf(f_out, "@ERROR: daemon security issue -- contact admin\n", name); @@ -1623,11 +1623,11 @@ static void create_pid_file(void) exit_cleanup(RERR_FILEIO); } } -#define PID_LSTAT(stp) do_lstat_atfd(pdfd, base, stp) +#define PID_LSTAT(stp) vfs_lstat_atfd(pdfd, base, stp) #define PID_UNLINK() do_unlink_atfd(pdfd, base, 0) #define PID_OPEN() do_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) #else -#define PID_LSTAT(stp) do_lstat(base, stp) +#define PID_LSTAT(stp) vfs_lstat(base, stp) #define PID_UNLINK() unlink(base) #define PID_OPEN() do_open(base, O_RDWR|O_CREAT|SAFE_NOFOLLOW, 0664) #endif @@ -1640,7 +1640,7 @@ static void create_pid_file(void) fail = S_ISREG(st1.st_mode) ? "open" : "create"; else if (!lock_range(pid_file_fd, 0, 4)) fail = "lock"; - else if (do_fstat(pid_file_fd, &st1) < 0) + else if (vfs_fstat(pid_file_fd, &st1) < 0) fail = "fstat opened"; else if (st1.st_size > (int)sizeof pidbuf) fail = "find small"; diff --git a/flist.c b/flist.c index e1258d355..1cf3c0ef9 100644 --- a/flist.c +++ b/flist.c @@ -309,17 +309,17 @@ int link_stat_at(int dfd, const char *name, STRUCT_STAT *stp, int follow_dirlink { #ifdef SUPPORT_LINKS if (copy_links) - return do_stat_atfd(dfd, name, stp); - if (do_lstat_atfd(dfd, name, stp) < 0) + return vfs_stat_atfd(dfd, name, stp); + if (vfs_lstat_atfd(dfd, name, stp) < 0) return -1; if (follow_dirlinks && S_ISLNK(stp->st_mode)) { STRUCT_STAT st; - if (do_stat_atfd(dfd, name, &st) == 0 && S_ISDIR(st.st_mode)) + if (vfs_stat_atfd(dfd, name, &st) == 0 && S_ISDIR(st.st_mode)) *stp = st; } return 0; #else - return do_stat_atfd(dfd, name, stp); + return vfs_stat_atfd(dfd, name, stp); #endif } diff --git a/generator.c b/generator.c index d2ca3b0b4..158872f31 100644 --- a/generator.c +++ b/generator.c @@ -1715,7 +1715,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } } if (relative_paths && !implied_dirs && file->mode != 0 - && do_stat_at(dn, &sx.st) < 0) { + && vfs_stat_at(dn, &sx.st) < 0) { if (dry_run) goto parent_is_dry_missing; if (make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0) { diff --git a/main.c b/main.c index c82952c2a..a22ecac3d 100644 --- a/main.c +++ b/main.c @@ -735,7 +735,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) } /* See what currently exists at the destination. */ - statret = do_stat(dest_path, &st); + statret = vfs_stat(dest_path, &st); cp = strrchr(dest_path, '/'); trailing_slash = cp && !cp[1]; @@ -752,7 +752,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) *cp = '/'; } if (ret) - statret = do_stat(dest_path, &st); + statret = vfs_stat(dest_path, &st); else errno = save_errno; } @@ -838,7 +838,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) dest_path = "/"; *cp = '\0'; - if (dry_run && mkpath_dest_arg && do_stat(dest_path, &st) < 0) { + if (dry_run && mkpath_dest_arg && vfs_stat(dest_path, &st) < 0) { /* --mkpath would have created this parent dir, but a dry run did * not, so don't chdir into it; flag the destination as not yet * present (as the dir-creation path above does) so the generator @@ -895,7 +895,7 @@ static void check_alt_basis_dirs(void) pathjoin(new, len, vfs.curr_dir, bdir); basis_dir[j] = bdir = new; } - if (do_stat(bdir, &st) < 0) + if (vfs_stat(bdir, &st) < 0) rprintf(FWARNING, "%s arg does not exist: %s\n", alt_dest_opt(0), bdir); else if (!S_ISDIR(st.st_mode)) rprintf(FWARNING, "%s arg is not a dir: %s\n", alt_dest_opt(0), bdir); @@ -1023,7 +1023,7 @@ static int do_recv(int f_in, int f_out, char *local_name) int ret; if (backup_dir_len > 1) backup_dir_buf[backup_dir_len-1] = '\0'; - ret = do_stat(backup_dir_buf, &st); + ret = vfs_stat(backup_dir_buf, &st); if (ret != 0 || !S_ISDIR(st.st_mode)) { if (ret == 0) { rprintf(FERROR, "The backup-dir is not a directory: %s\n", backup_dir_buf); @@ -1043,7 +1043,7 @@ static int do_recv(int f_in, int f_out, char *local_name) if (tmpdir) { STRUCT_STAT st; - int ret = do_stat(tmpdir, &st); + int ret = vfs_stat(tmpdir, &st); if (ret < 0 || !S_ISDIR(st.st_mode)) { if (ret == 0) { rprintf(FERROR, "The temp-dir is not a directory: %s\n", tmpdir); diff --git a/options.c b/options.c index f4741173d..b6a55ae23 100644 --- a/options.c +++ b/options.c @@ -331,7 +331,7 @@ static struct output_struct debug_words[COUNT_DEBUG+1] = { }; static int verbose = 0; -static int do_stats = 0; +static int vfs_stats = 0; static int do_progress = 0; static int daemon_opt; /* sets am_daemon after option error-reporting */ static int F_option_cnt = 0; @@ -621,7 +621,7 @@ static struct poptOption long_options[] = { {"quiet", 'q', POPT_ARG_NONE, 0, 'q', 0, 0 }, {"motd", 0, POPT_ARG_VAL, &output_motd, 1, 0, 0 }, {"no-motd", 0, POPT_ARG_VAL, &output_motd, 0, 0, 0 }, - {"stats", 0, POPT_ARG_NONE, &do_stats, 0, 0, 0 }, + {"stats", 0, POPT_ARG_NONE, &vfs_stats, 0, 0, 0 }, {"human-readable", 'h', POPT_ARG_NONE, 0, 'h', 0, 0}, {"no-human-readable",0, POPT_ARG_VAL, &human_readable, 0, 0, 0}, {"no-h", 0, POPT_ARG_VAL, &human_readable, 0, 0, 0}, @@ -2178,7 +2178,7 @@ int parse_arguments(int *argc_p, const char ***argv_p) set_output_verbosity(verbose, DEFAULT_PRIORITY); - if (do_stats) { + if (vfs_stats) { parse_output_words(info_words, info_levels, verbose > 1 ? "stats3" : "stats2", DEFAULT_PRIORITY); } @@ -2372,7 +2372,7 @@ int parse_arguments(int *argc_p, const char ***argv_p) STRUCT_STAT st; char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */ strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */ - if (do_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { + if (vfs_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { rprintf(FERROR, "Symlink munging is unsafe when a %s directory exists.\n", prefix); exit_cleanup(RERR_UNSUPPORTED); @@ -3010,7 +3010,7 @@ void server_options(char **args, int *argc_p) args[ac++] = "--super"; if (size_only) args[ac++] = "--size-only"; - if (do_stats) + if (vfs_stats) args[ac++] = "--stats"; } else { if (skip_compress) diff --git a/params.c b/params.c index b31aec2b1..df3bbee3f 100644 --- a/params.c +++ b/params.c @@ -416,7 +416,7 @@ static int include_config(char *include, int manage_globals) char *match = manage_globals ? "*.conf" : "*.inc"; int ret; - if (do_stat(include, &sb) < 0) { + if (vfs_stat(include, &sb) < 0) { rsyserr(FLOG, errno, "unable to stat config file \"%s\"", include); return 0; } diff --git a/receiver.c b/receiver.c index 298a521ca..2954b7e81 100644 --- a/receiver.c +++ b/receiver.c @@ -1116,7 +1116,7 @@ int recv_files(int f_in, int f_out, char *local_name) if (fd1 == -1) { st.st_mode = 0; st.st_size = 0; - } else if (do_fstat(fd1,&st) != 0) { + } else if (vfs_fstat(fd1,&st) != 0) { rsyserr(FERROR_XFER, errno, "fstat %s failed", full_fname(fnamecmp)); discard_receive_data(f_in, file); diff --git a/rsync.h b/rsync.h index 477033ede..e6c5bb1af 100644 --- a/rsync.h +++ b/rsync.h @@ -1241,9 +1241,9 @@ struct name_num_obj { #endif #ifndef SUPPORT_XATTRS -#define x_stat(fn,fst,xst) do_stat(fn,fst) -#define x_lstat(fn,fst,xst) do_lstat(fn,fst) -#define x_fstat(fd,fst,xst) do_fstat(fd,fst) +#define x_stat(fn,fst,xst) vfs_stat(fn,fst) +#define x_lstat(fn,fst,xst) vfs_lstat(fn,fst) +#define x_fstat(fd,fst,xst) vfs_fstat(fd,fst) #endif /* We have replacement versions of these if they're missing. */ diff --git a/sender.c b/sender.c index 827a250b5..e5acf5008 100644 --- a/sender.c +++ b/sender.c @@ -392,8 +392,8 @@ void successful_send(int ndx) } if (dfd >= 0 - ? (copy_links ? do_stat_atfd(dfd, bname, &st) : do_lstat_atfd(dfd, bname, &st)) < 0 - : (copy_links ? do_stat(fname, &st) : do_lstat(fname, &st)) < 0) { + ? (copy_links ? vfs_stat_atfd(dfd, bname, &st) : vfs_lstat_atfd(dfd, bname, &st)) < 0 + : (copy_links ? vfs_stat(fname, &st) : vfs_lstat(fname, &st)) < 0) { failed_op = "re-lstat"; goto failed; } @@ -693,7 +693,7 @@ void send_files(int f_in, int f_out) } /* map the local file */ - if (do_fstat(fd, &st) != 0) { + if (vfs_fstat(fd, &st) != 0) { io_error |= IOERR_GENERAL; rsyserr(FERROR_XFER, errno, "fstat failed"); free_sums(s); diff --git a/syscall.c b/syscall.c index b9c1c44e9..2155776a4 100644 --- a/syscall.c +++ b/syscall.c @@ -82,20 +82,6 @@ struct create_time { #endif #endif -#define RETURN_ERROR_IF(x,e) \ - do { \ - if (x) { \ - errno = (e); \ - return -1; \ - } \ - } while (0) - -#define RETURN_ERROR_IF_RO_OR_LO RETURN_ERROR_IF(read_only || list_only, EROFS) - -/* A NULL path reaching one of the path-forwarding wrappers below is always a - * caller bug; reject it rather than forwarding NULL to libc. Also quiets the - * static analyzer's interprocedural nonnull false positives. */ -#define RETURN_ERROR_IF_NULL(p) RETURN_ERROR_IF(!(p), EFAULT) int do_unlink(const char *path) { @@ -1070,8 +1056,7 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) # ifdef O_NOFOLLOW { STRUCT_STAT st; - int oflags = O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY; - if (do_lstat_atfd(dfd, name, &st) < 0) + if (vfs_lstat_atfd(dfd, name, &st) < 0) return -1; if (S_ISLNK(st.st_mode)) { errno = ELOOP; /* refuse to chmod through a symlink leaf */ @@ -1588,122 +1573,6 @@ int do_mkstemp(char *template, mode_t perms) #endif } -int do_stat(const char *path, STRUCT_STAT *st) -{ - RETURN_ERROR_IF_NULL(path); -#ifdef USE_STAT64_FUNCS - return stat64(path, st); -#else - return stat(path, st); -#endif -} - -int do_lstat(const char *path, STRUCT_STAT *st) -{ - RETURN_ERROR_IF_NULL(path); -#ifdef SUPPORT_LINKS -# ifdef USE_STAT64_FUNCS - return lstat64(path, st); -# else - return lstat(path, st); -# endif -#else - return do_stat(path, st); -#endif -} - -/* - Symlink-race-safe variants of do_stat() / do_lstat() for receiver- - side use. See the comment on do_chmod_at() for the threat model. - stat() and lstat() resolve parent components, so a parent-symlink - swap can make the receiver's stat see attributes of a victim file - outside the module -- which then drives later behaviour (e.g. - "this isn't a directory, delete it" -> attacker-controlled unlink - on something outside the module). - - Defence: open the parent under vfs_resolve_open() and use - fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against - that dirfd. Same fall-through gating as the other wrappers. -*/ -static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return fallback(path, st); - dfd = vfs_owner_walk_parent(path, &bname); - if (dfd < 0) - return -1; - ret = fstatat(dfd, bname, st, at_flags); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return fallback(path, st); - - if (!path || !*path || *path == '/') - return fallback(path, st); - - slash = strrchr(path, '/'); - if (!slash) - return fallback(path, st); - - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = fstatat(dfd, bname, st, at_flags); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return fallback(path, st); -#endif -} - -int do_stat_at(const char *path, STRUCT_STAT *st) -{ - return do_xstat_at(path, st, 0, do_stat); -} - -int do_lstat_at(const char *path, STRUCT_STAT *st) -{ -#ifdef SUPPORT_LINKS - return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, do_lstat); -#else - return do_xstat_at(path, st, 0, do_stat); -#endif -} - -int do_fstat(int fd, STRUCT_STAT *st) -{ -#ifdef USE_STAT64_FUNCS - return fstat64(fd, st); -#else - return fstat(fd, st); -#endif -} OFF_T do_lseek(int fd, OFF_T offset, int whence) { @@ -2041,7 +1910,7 @@ OFF_T do_fallocate(int fd, OFF_T offset, OFF_T length) return ret; if (opts == 0) { STRUCT_STAT st; - if (do_fstat(fd, &st) < 0) + if (vfs_fstat(fd, &st) < 0) return length; return st.st_blocks * S_BLKSIZE; } @@ -2120,7 +1989,7 @@ int do_open_nofollow(const char *pathname, int flags) #ifdef O_NOFOLLOW fd = open(pathname, flags|O_NOFOLLOW); #else - if (do_lstat(pathname, &l_st) < 0) + if (vfs_lstat(pathname, &l_st) < 0) return -1; if (S_ISLNK(l_st.st_mode)) { errno = ELOOP; @@ -2128,7 +1997,7 @@ int do_open_nofollow(const char *pathname, int flags) } if ((fd = open(pathname, flags)) < 0) return fd; - if (do_fstat(fd, &f_st) < 0) { + if (vfs_fstat(fd, &f_st) < 0) { close_and_return_error: { int save_errno = errno; @@ -2582,28 +2451,3 @@ int do_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new } #endif -int do_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st) -{ -#ifdef AT_FDCWD -# ifdef SUPPORT_LINKS - return fstatat(dfd, name, st, AT_SYMLINK_NOFOLLOW); -# else - return fstatat(dfd, name, st, 0); -# endif -#else - (void)dfd; (void)name; (void)st; - errno = ENOSYS; - return -1; -#endif -} - -int do_stat_atfd(int dfd, const char *name, STRUCT_STAT *st) -{ -#ifdef AT_FDCWD - return fstatat(dfd, name, st, 0); -#else - (void)dfd; (void)name; (void)st; - errno = ENOSYS; - return -1; -#endif -} diff --git a/tls.c b/tls.c index e05f7ec23..b7dfac7ea 100644 --- a/tls.c +++ b/tls.c @@ -160,7 +160,7 @@ static void list_file(const char *fname) char linkbuf[4096]; int nsecs; - if (do_lstat(fname, &buf) < 0) + if (vfs_lstat(fname, &buf) < 0) failed("stat", fname); #ifdef SUPPORT_CRTIMES if (display_crtimes && (crtime = get_create_time(fname, &buf)) == 0) diff --git a/util1.c b/util1.c index a4fbbc338..1587135b8 100644 --- a/util1.c +++ b/util1.c @@ -226,7 +226,7 @@ int make_path(char *fname, int flags) for (p = end; ; ) { if (dry_run) { STRUCT_STAT st; - if (do_stat(fname, &st) == 0) { + if (vfs_stat(fname, &st) == 0) { if (S_ISDIR(st.st_mode)) errno = EEXIST; else @@ -239,7 +239,7 @@ int make_path(char *fname, int flags) if (errno != ENOENT) { STRUCT_STAT st; - if (errno != EEXIST || (do_stat(fname, &st) == 0 && !S_ISDIR(st.st_mode))) + if (errno != EEXIST || (vfs_stat(fname, &st) == 0 && !S_ISDIR(st.st_mode))) ret = -ret - 1; break; } @@ -425,7 +425,7 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) /* Try to preallocate enough space for file's eventual length. Can * reduce fragmentation on filesystems like ext4, xfs, and NTFS. */ - if (do_fstat(ifd, &srcst) < 0) + if (vfs_fstat(ifd, &srcst) < 0) rsyserr(FWARNING, errno, "fstat %s", full_fname(source)); else if (srcst.st_size > 0) { prealloc_len = do_fallocate(ofd, 0, srcst.st_size); @@ -774,7 +774,7 @@ static inline void call_glob_match(const char *name, int len, int from_glob, STRUCT_STAT st; int is_dir; - if (do_stat(glob.arg_buf, &st) != 0) + if (vfs_stat(glob.arg_buf, &st) != 0) return; is_dir = S_ISDIR(st.st_mode) != 0; if (arg && !is_dir) @@ -1491,7 +1491,7 @@ int handle_partial_dir(const char *fname, int create) vfs.operator_path_resolve = 1; if (create) { STRUCT_STAT st; - int statret = do_lstat_at(dir, &st); + int statret = vfs_lstat_at(dir, &st); if (statret == 0 && !S_ISDIR(st.st_mode)) { if (do_unlink_at(dir) < 0) { vfs.operator_path_resolve = 0; diff --git a/vfs/stat.c b/vfs/stat.c new file mode 100644 index 000000000..d5447c2c5 --- /dev/null +++ b/vfs/stat.c @@ -0,0 +1,160 @@ +/* + * vfs/stat.c - stat/lstat/fstat wrappers (path, parent-resolved, held-dirfd). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +int vfs_stat(const char *path, STRUCT_STAT *st) +{ + RETURN_ERROR_IF_NULL(path); +#ifdef USE_STAT64_FUNCS + return stat64(path, st); +#else + return stat(path, st); +#endif +} + +int vfs_lstat(const char *path, STRUCT_STAT *st) +{ + RETURN_ERROR_IF_NULL(path); +#ifdef SUPPORT_LINKS +# ifdef USE_STAT64_FUNCS + return lstat64(path, st); +# else + return lstat(path, st); +# endif +#else + return vfs_stat(path, st); +#endif +} + +/* + Symlink-race-safe variants of vfs_stat() / vfs_lstat() for receiver- + side use. See the comment on do_chmod_at() for the threat model. + stat() and lstat() resolve parent components, so a parent-symlink + swap can make the receiver's stat see attributes of a victim file + outside the module -- which then drives later behaviour (e.g. + "this isn't a directory, delete it" -> attacker-controlled unlink + on something outside the module). + + Defence: open the parent under vfs_resolve_open() and use + fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against + that dirfd. Same fall-through gating as the other wrappers. +*/ +static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return fallback(path, st); + dfd = vfs_owner_walk_parent(path, &bname); + if (dfd < 0) + return -1; + ret = fstatat(dfd, bname, st, at_flags); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + + if (!vfs_relpath_active()) + return fallback(path, st); + + if (!path || !*path || *path == '/') + return fallback(path, st); + + slash = strrchr(path, '/'); + if (!slash) + return fallback(path, st); + + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = fstatat(dfd, bname, st, at_flags); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return fallback(path, st); +#endif +} + +int vfs_stat_at(const char *path, STRUCT_STAT *st) +{ + return do_xstat_at(path, st, 0, vfs_stat); +} + +int vfs_lstat_at(const char *path, STRUCT_STAT *st) +{ +#ifdef SUPPORT_LINKS + return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs_lstat); +#else + return do_xstat_at(path, st, 0, vfs_stat); +#endif +} + +int vfs_fstat(int fd, STRUCT_STAT *st) +{ +#ifdef USE_STAT64_FUNCS + return fstat64(fd, st); +#else + return fstat(fd, st); +#endif +} + +int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st) +{ +#ifdef AT_FDCWD +# ifdef SUPPORT_LINKS + return fstatat(dfd, name, st, AT_SYMLINK_NOFOLLOW); +# else + return fstatat(dfd, name, st, 0); +# endif +#else + (void)dfd; (void)name; (void)st; + errno = ENOSYS; + return -1; +#endif +} + +int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st) +{ +#ifdef AT_FDCWD + return fstatat(dfd, name, st, 0); +#else + (void)dfd; (void)name; (void)st; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index a3938f2ac..3faa4eba6 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -74,4 +74,13 @@ int vfs_path_dirfd(const char *anchor, const char *dirpath); int vfs_cached_dirfd(const char *path, const struct file_struct *file); void vfs_dircache_reset(void); +/* stat/lstat/fstat (vfs/stat.c). */ +int vfs_stat(const char *path, STRUCT_STAT *st); +int vfs_lstat(const char *path, STRUCT_STAT *st); +int vfs_fstat(int fd, STRUCT_STAT *st); +int vfs_stat_at(const char *path, STRUCT_STAT *st); +int vfs_lstat_at(const char *path, STRUCT_STAT *st); +int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st); +int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st); + #endif /* RSYNC_VFS_H */ diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index 41576aff9..c3af322ba 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -18,10 +18,28 @@ * clientserver.c / syscall.c). Centralized here so each vfs/ source picks them * up from one place rather than re-declaring them. */ extern int am_daemon; +extern int read_only; +extern int list_only; extern char *module_dir; extern unsigned int module_dirlen; extern int module_dirfd; +/* Dry-run / read-only guard macros shared by the syscall wrappers. */ +#define RETURN_ERROR_IF(x,e) \ + do { \ + if (x) { \ + errno = (e); \ + return -1; \ + } \ + } while (0) + +#define RETURN_ERROR_IF_RO_OR_LO RETURN_ERROR_IF(read_only || list_only, EROFS) + +/* A NULL path reaching one of the path-forwarding wrappers is always a caller + * bug; reject it rather than forwarding NULL to libc. Also quiets the static + * analyzer's interprocedural nonnull false positives. */ +#define RETURN_ERROR_IF_NULL(p) RETURN_ERROR_IF(!(p), EFAULT) + /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); int abspath_excluded_by_module(const char *abspath, int name_is_dir); diff --git a/xattrs.c b/xattrs.c index ab7120be1..39f5bdac6 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1286,7 +1286,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode, } if (fd >= 0) { - if (do_fstat(fd, &fst) < 0) { + if (vfs_fstat(fd, &fst) < 0) { rsyserr(FERROR_XFER, errno, "failed to re-stat %s", full_fname(fname)); return -1; @@ -1361,9 +1361,9 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) /* Use the *_at variants so that on a daemon-no-chroot deployment * the metadata read goes through a secure parent dirfd instead * of bare path resolution. The *_at wrappers fall through to - * plain do_stat outside the daemon-no-chroot context, so this + * plain vfs_stat outside the daemon-no-chroot context, so this * change is transparent for non-daemon use. */ - int ret = do_stat_at(fname, fst); + int ret = vfs_stat_at(fname, fst); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; @@ -1371,7 +1371,7 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) { - int ret = do_lstat_at(fname, fst); + int ret = vfs_lstat_at(fname, fst); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; @@ -1379,7 +1379,7 @@ int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) int x_fstat(int fd, STRUCT_STAT *fst, STRUCT_STAT *xst) { - int ret = do_fstat(fd, fst); + int ret = vfs_fstat(fd, fst); if ((ret < 0 || get_stat_xattr(NULL, fd, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; From 02ea67ab6c8ad5599c7b29d6403a565402f3f4f2 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:50:58 +1000 Subject: [PATCH 11/69] vfs: move the rename family into vfs/rename.c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate do_rename / do_rename_at / do_rename_atfd out of syscall.c into vfs/rename.c as vfs_rename / vfs_rename_at / vfs_rename_atfd, declared in vfs/vfs.h. Function bodies unchanged. Also centralize the option-global externs (dry_run, am_root, am_sender, inplace, preserve_*, open_noatime, copy_*, insecure_links, module_id, …) in vfs/vfs_internal.h, replacing syscall.c's local extern block, so each relocated family picks them up from one place. No behavior change. --- Makefile.in | 2 +- backup.c | 2 +- generator.c | 4 +- receiver.c | 2 +- rsync.c | 2 +- syscall.c | 192 --------------------------------------------- t_rename_secure.c | 10 +-- util1.c | 6 +- vfs/rename.c | 170 +++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 5 ++ vfs/vfs_internal.h | 13 +++ 11 files changed, 202 insertions(+), 206 deletions(-) create mode 100644 vfs/rename.c diff --git a/Makefile.in b/Makefile.in index f80132e6d..0f764c1c1 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index 09f546857..abc7e77cc 100644 --- a/backup.c +++ b/backup.c @@ -245,7 +245,7 @@ static inline int link_or_rename(const char *from, const char *to, return 0; } #endif - if (do_rename_at(from, to) == 0) { + if (vfs_rename_at(from, to) == 0) { if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { /* If someone has hard-linked the file into the backup * dir, rename() might return success but do nothing! */ diff --git a/generator.c b/generator.c index 158872f31..31f5bb9b1 100644 --- a/generator.c +++ b/generator.c @@ -1507,9 +1507,9 @@ static int gen_entry_rename(const char *opath, const char *npath, struct file_st if (odfd >= 0 && ndfd >= 0) { const char *os = strrchr(opath, '/'); const char *ns = strrchr(npath, '/'); - return do_rename_atfd(odfd, os ? os + 1 : opath, ndfd, ns ? ns + 1 : npath); + return vfs_rename_atfd(odfd, os ? os + 1 : opath, ndfd, ns ? ns + 1 : npath); } - return do_rename_at(opath, npath); + return vfs_rename_at(opath, npath); } #ifdef SUPPORT_XATTRS diff --git a/receiver.c b/receiver.c index 2954b7e81..17efee8c3 100644 --- a/receiver.c +++ b/receiver.c @@ -688,7 +688,7 @@ static void handle_delayed_updates(char *local_name) * an excluded subtree. */ int rret; vfs.operator_path_resolve = 1; - rret = do_rename_at(partialptr, fname); + rret = vfs_rename_at(partialptr, fname); vfs.operator_path_resolve = 0; if (rret < 0) { rsyserr(FERROR_XFER, errno, diff --git a/rsync.c b/rsync.c index 1b9c8464d..160cdfdb5 100644 --- a/rsync.c +++ b/rsync.c @@ -938,7 +938,7 @@ int finish_transfer(const char *fname, const char *fnametmp, ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME); if (temp_copy_name) { - if (do_rename_at(fnametmp, fname) < 0) { + if (vfs_rename_at(fnametmp, fname) < 0) { rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\"", full_fname(fnametmp), fname); return 0; diff --git a/syscall.c b/syscall.c index 2155776a4..865a2ecae 100644 --- a/syscall.c +++ b/syscall.c @@ -40,21 +40,6 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -extern int dry_run; -extern int am_root; -extern int am_sender; -extern int read_only; -extern int list_only; -extern int inplace; -extern int preallocate_files; -extern int sparse_files; -extern int preserve_perms; -extern int preserve_executability; -extern int open_noatime; -extern int copy_links; -extern int copy_unsafe_links; -extern int insecure_links; -extern int module_id; @@ -1258,171 +1243,6 @@ int do_chmod_at(const char *fname, mode_t mode) } #endif -int do_rename(const char *old_path, const char *new_path) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return rename(old_path, new_path); -} - -/* - Symlink-race-safe variant of do_rename() for receiver-side use. See - the comment on do_chmod_at() for the threat model and design rationale. - - rename() is the central tmp -> final operation in rsync; if either the - source or the destination has an attacker-substituted symlink in one - of its parent components, the rename can publish or vanish files - outside the module. Defence: open the parent of *each* path under - vfs_resolve_open() and use renameat() against the resulting - dirfds. When old_path and new_path share the same parent (the common - case -- tmp file living next to its final name), we reuse the same - dirfd for both sides. - - Falls through to do_rename() in dry-run, non-daemon, chrooted and - absolute-path cases, identical to the other do_*_at() wrappers. -*/ -int do_rename_at(const char *old_path, const char *new_path) -{ -#ifdef AT_FDCWD - char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; - const char *old_bname, *new_bname; - const char *old_slash, *new_slash; - int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; - BOOL old_owns = False, new_owns = False; - int ret = -1, e; - size_t old_dlen = 0, new_dlen = 0; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (!vfs_relpath_active()) - return do_rename(old_path, new_path); - - if (!old_path || !*old_path || !new_path || !*new_path) - return do_rename(old_path, new_path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path (e.g. a --backup-dir destination or a --temp-dir - * source): resolve each side's parent via the ownership walk (follow - * uid0/euid symlinks, refuse others; absolute and relative alike). */ - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return do_rename(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname); - if (old_dfd < 0) - return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname); - if (new_dfd < 0) { - e = errno; - close(old_dfd); - errno = e; - return -1; - } - ret = renameat(old_dfd, old_bname, new_dfd, new_bname); - e = errno; - close(new_dfd); - close(old_dfd); - errno = e; - return ret; - } -#endif - - old_slash = strrchr(old_path, '/'); - new_slash = strrchr(new_path, '/'); - - /* Confine each side independently. A *relative* side is a transfer path, - * confined beneath the tree via secure_relative_open(). An *absolute* side is - * an operator path (an absolute --temp-dir/--partial-dir temp file): resolve - * its parent via the ownership walk so a flipped foreign-owned parent symlink - * can't redirect the rename out of tree, while still allowing the operator's - * own dirs/".."/uid0-or-euid symlinks. (--insecure-links keeps the legacy - * unconfined AT_FDCWD path.) Doing each side independently means an absolute - * source never disables confinement of a relative destination. */ - if (*old_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!symlink_optout_allowed()) { - operator_path_resolve = 1; /* operator side: enforce module-exclude */ - old_dfd = owner_walk_parent(old_path, &old_bname); - operator_path_resolve = 0; - if (old_dfd < 0) - return -1; - old_owns = True; - } else -#endif - old_bname = old_path; - } else if (old_slash) { - old_dlen = old_slash - old_path; - if (old_dlen >= sizeof old_dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(old_dirpath, old_path, old_dlen); - old_dirpath[old_dlen] = '\0'; - old_bname = old_slash + 1; - old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (old_dfd < 0) - return -1; - old_owns = True; - } else { - old_bname = old_path; - } - - if (*new_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!symlink_optout_allowed()) { - operator_path_resolve = 1; /* operator side: enforce module-exclude */ - new_dfd = owner_walk_parent(new_path, &new_bname); - operator_path_resolve = 0; - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } else -#endif - new_bname = new_path; - } else if (new_slash) { - new_dlen = new_slash - new_path; - if (new_dlen >= sizeof new_dirpath) { - e = ENAMETOOLONG; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - memcpy(new_dirpath, new_path, new_dlen); - new_dirpath[new_dlen] = '\0'; - new_bname = new_slash + 1; - if (old_owns && old_dlen == new_dlen - && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { - new_dfd = old_dfd; - } else { - new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } - } else { - new_bname = new_path; - } - - ret = renameat(old_dfd, old_bname, new_dfd, new_bname); - e = errno; - if (new_owns) - close(new_dfd); - if (old_owns) - close(old_dfd); - errno = e; - return ret; -#else - return do_rename(old_path, new_path); -#endif -} #ifdef HAVE_FTRUNCATE int do_ftruncate(int fd, OFF_T size) @@ -2423,18 +2243,6 @@ int do_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) #endif } -int do_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return renameat(old_dfd, old_name, new_dfd, new_name); -#else - (void)old_dfd; (void)old_name; (void)new_dfd; (void)new_name; - errno = ENOSYS; - return -1; -#endif -} #if defined HAVE_LINK || defined HAVE_LINKAT int do_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags) diff --git a/t_rename_secure.c b/t_rename_secure.c index 9de9f761f..51bde75e1 100644 --- a/t_rename_secure.c +++ b/t_rename_secure.c @@ -1,5 +1,5 @@ /* - * Test harness for do_rename_at(): a mixed top-level/slashed rename must still + * Test harness for vfs_rename_at(): a mixed top-level/slashed rename must still * resolve the slashed side's parent under vfs_resolve_open() rather than * fall back to plain rename(). Not linked into rsync. GPL version 2. */ @@ -30,14 +30,14 @@ static int vulnerable_mixed_rename_at(const char *old_path, const char *new_path if (!old_path || !*old_path || *old_path == '/' || !new_path || !*new_path || *new_path == '/') - return do_rename(old_path, new_path); + return vfs_rename(old_path, new_path); old_slash = strrchr(old_path, '/'); new_slash = strrchr(new_path, '/'); if (!old_slash || !new_slash) - return do_rename(old_path, new_path); + return vfs_rename(old_path, new_path); - return do_rename_at(old_path, new_path); + return vfs_rename_at(old_path, new_path); } #endif @@ -64,7 +64,7 @@ static void check_rename(const char *label, const char *old_path, int saved_errno; errno = 0; - rc = do_rename_at(old_path, new_path); + rc = vfs_rename_at(old_path, new_path); saved_errno = errno; got_ok = rc == 0; diff --git a/util1.c b/util1.c index 1587135b8..e8ca289e9 100644 --- a/util1.c +++ b/util1.c @@ -554,7 +554,7 @@ int robust_unlink(const char *fname) } /* maybe we should return rename()'s exit status? Nah. */ - if (do_rename_at(fname, path) != 0) { + if (vfs_rename_at(fname, path) != 0) { errno = ETXTBSY; return -1; } @@ -585,9 +585,9 @@ int robust_rename(const char *from, const char *to, const char *partialptr, if (ofd >= 0 && nfd >= 0) { const char *os = strrchr(from, '/'); const char *ns = strrchr(to, '/'); - rr = do_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); + rr = vfs_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); } else - rr = do_rename_at(from, to); + rr = vfs_rename_at(from, to); if (rr == 0) return 0; diff --git a/vfs/rename.c b/vfs/rename.c new file mode 100644 index 000000000..bd93e0dbc --- /dev/null +++ b/vfs/rename.c @@ -0,0 +1,170 @@ +/* + * vfs/rename.c - rename wrappers (path, parent-resolved, held-dirfd). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +int vfs_rename(const char *old_path, const char *new_path) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return rename(old_path, new_path); +} + +/* + Symlink-race-safe variant of vfs_rename() for receiver-side use. See + the comment on do_chmod_at() for the threat model and design rationale. + + rename() is the central tmp -> final operation in rsync; if either the + source or the destination has an attacker-substituted symlink in one + of its parent components, the rename can publish or vanish files + outside the module. Defence: open the parent of *each* path under + vfs_resolve_open() and use renameat() against the resulting + dirfds. When old_path and new_path share the same parent (the common + case -- tmp file living next to its final name), we reuse the same + dirfd for both sides. + + Falls through to vfs_rename() in dry-run, non-daemon, chrooted and + absolute-path cases, identical to the other do_*_at() wrappers. +*/ +int vfs_rename_at(const char *old_path, const char *new_path) +{ +#ifdef AT_FDCWD + char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; + const char *old_bname, *new_bname; + const char *old_slash, *new_slash; + int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; + BOOL old_owns = False, new_owns = False; + int ret = -1, e; + size_t old_dlen = 0, new_dlen = 0; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (!vfs_relpath_active()) + return vfs_rename(old_path, new_path); + + if (!old_path || !*old_path || !new_path || !*new_path) + return vfs_rename(old_path, new_path); + +#if defined O_NOFOLLOW && defined O_DIRECTORY + /* Operator-supplied path (e.g. a --backup-dir destination or a --temp-dir + * source): resolve each side's parent via the ownership walk (follow + * uid0/euid symlinks, refuse others; absolute and relative alike). */ + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return vfs_rename(old_path, new_path); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname); + if (old_dfd < 0) + return -1; + new_dfd = vfs_owner_walk_parent(new_path, &new_bname); + if (new_dfd < 0) { + e = errno; + close(old_dfd); + errno = e; + return -1; + } + ret = renameat(old_dfd, old_bname, new_dfd, new_bname); + e = errno; + close(new_dfd); + close(old_dfd); + errno = e; + return ret; + } +#endif + + old_slash = strrchr(old_path, '/'); + new_slash = strrchr(new_path, '/'); + + /* An absolute path uses AT_FDCWD with the full path; only a *relative* side + * is confined under the secure resolver. Confine each side independently: + * an absolute source (e.g. an absolute --temp-dir temp file) must NOT + * disable confinement of a relative destination, or finish_transfer's + * tmp->final rename re-resolves the dest from the path and a flipped parent + * symlink writes the file outside the tree (a symlink-race write escape). */ + if (*old_path == '/') { + old_bname = old_path; + } else if (old_slash) { + old_dlen = old_slash - old_path; + if (old_dlen >= sizeof old_dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(old_dirpath, old_path, old_dlen); + old_dirpath[old_dlen] = '\0'; + old_bname = old_slash + 1; + old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); + if (old_dfd < 0) + return -1; + old_owns = True; + } else { + old_bname = old_path; + } + + if (*new_path == '/') { + new_bname = new_path; + } else if (new_slash) { + new_dlen = new_slash - new_path; + if (new_dlen >= sizeof new_dirpath) { + e = ENAMETOOLONG; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + memcpy(new_dirpath, new_path, new_dlen); + new_dirpath[new_dlen] = '\0'; + new_bname = new_slash + 1; + if (old_owns && old_dlen == new_dlen + && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { + new_dfd = old_dfd; + } else { + new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); + if (new_dfd < 0) { + e = errno; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + new_owns = True; + } + } else { + new_bname = new_path; + } + + ret = renameat(old_dfd, old_bname, new_dfd, new_bname); + e = errno; + if (new_owns) + close(new_dfd); + if (old_owns) + close(old_dfd); + errno = e; + return ret; +#else + return vfs_rename(old_path, new_path); +#endif +} + +int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return renameat(old_dfd, old_name, new_dfd, new_name); +#else + (void)old_dfd; (void)old_name; (void)new_dfd; (void)new_name; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 3faa4eba6..9d9a386b8 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -83,4 +83,9 @@ int vfs_lstat_at(const char *path, STRUCT_STAT *st); int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st); int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st); +/* rename (vfs/rename.c). */ +int vfs_rename(const char *old_path, const char *new_path); +int vfs_rename_at(const char *old_path, const char *new_path); +int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name); + #endif /* RSYNC_VFS_H */ diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index c3af322ba..6e4cb161d 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -17,9 +17,22 @@ /* Option/daemon globals the VFS internals read (defined in options.c / * clientserver.c / syscall.c). Centralized here so each vfs/ source picks them * up from one place rather than re-declaring them. */ +extern int dry_run; +extern int am_root; +extern int am_sender; extern int am_daemon; extern int read_only; extern int list_only; +extern int inplace; +extern int preallocate_files; +extern int sparse_files; +extern int preserve_perms; +extern int preserve_executability; +extern int open_noatime; +extern int copy_links; +extern int copy_unsafe_links; +extern int insecure_links; +extern int module_id; extern char *module_dir; extern unsigned int module_dirlen; extern int module_dirfd; From 7f03474b2b4980b603a18a0886f36307f21ff28d Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:53:53 +1000 Subject: [PATCH 12/69] vfs: move the unlink/rmdir family into vfs/unlink.c Relocate do_unlink/do_unlink_at/do_unlink_atfd and do_rmdir/do_rmdir_at out of syscall.c into vfs/unlink.c as the vfs_* names, declared in vfs/vfs.h. Function bodies unchanged; no behavior change. --- Makefile.in | 2 +- cleanup.c | 2 +- clientserver.c | 2 +- delete.c | 4 +- generator.c | 12 ++-- receiver.c | 6 +- rsync.c | 2 +- sender.c | 2 +- syscall.c | 162 ----------------------------------------------- util1.c | 12 ++-- vfs/unlink.c | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 7 +++ 12 files changed, 196 insertions(+), 184 deletions(-) create mode 100644 vfs/unlink.c diff --git a/Makefile.in b/Makefile.in index 0f764c1c1..863917b64 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/cleanup.c b/cleanup.c index 178695f45..7674baf32 100644 --- a/cleanup.c +++ b/cleanup.c @@ -198,7 +198,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line) switch_step++; if (cleanup_fname) - do_unlink_at(cleanup_fname); + vfs_unlink_at(cleanup_fname); if (exit_code) kill_all(SIGUSR1); if (cleanup_pid && cleanup_pid == getpid()) { diff --git a/clientserver.c b/clientserver.c index ab68c1f5f..80ecd07c4 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1624,7 +1624,7 @@ static void create_pid_file(void) } } #define PID_LSTAT(stp) vfs_lstat_atfd(pdfd, base, stp) -#define PID_UNLINK() do_unlink_atfd(pdfd, base, 0) +#define PID_UNLINK() vfs_unlink_atfd(pdfd, base, 0) #define PID_OPEN() do_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) #else #define PID_LSTAT(stp) vfs_lstat(base, stp) diff --git a/delete.c b/delete.c index d9eee6383..47ad4fd88 100644 --- a/delete.c +++ b/delete.c @@ -72,7 +72,7 @@ static int del_unlink(const char *fbuf) { const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); - if (dfd >= 0 && do_unlink_atfd(dfd, leaf, 0) == 0) + if (dfd >= 0 && vfs_unlink_atfd(dfd, leaf, 0) == 0) return 0; return robust_unlink(fbuf); /* fall back (ETXTBSY retry, or not held) */ } @@ -223,7 +223,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); what = "rmdir"; - ok = (dfd >= 0 ? do_unlink_atfd(dfd, leaf, AT_REMOVEDIR) : do_rmdir_at(fbuf)) == 0; + ok = (dfd >= 0 ? vfs_unlink_atfd(dfd, leaf, AT_REMOVEDIR) : vfs_rmdir_at(fbuf)) == 0; } else { if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) { what = "make_backup"; diff --git a/generator.c b/generator.c index 31f5bb9b1..478d12354 100644 --- a/generator.c +++ b/generator.c @@ -938,8 +938,8 @@ static int copy_altdest_file(const char *src, const char *dest, struct file_stru } /* Try to clean up. copy_to's parent components are peer-named * and can be raced to a symlink, so resolve each with O_NOFOLLOW - * via do_unlink_at() like the other generator-side unlinks. */ - do_unlink_at(copy_to); + * via vfs_unlink_at() like the other generator-side unlinks. */ + vfs_unlink_at(copy_to); cleanup_disable(); return -1; } @@ -1114,7 +1114,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, if (find_exact_for_existing) { if (alt_dest_type == LINK_DEST && real_st.st_dev == sxp->st.st_dev && real_st.st_ino == sxp->st.st_ino) return -1; - if (do_unlink_at(fname) < 0 && errno != ENOENT) + if (vfs_unlink_at(fname) < 0 && errno != ENOENT) goto got_nothing_for_ya; } #ifdef SUPPORT_HARD_LINKS @@ -1492,9 +1492,9 @@ static int gen_entry_unlink(const char *path, struct file_struct *file) int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); - return do_unlink_atfd(dfd, slash ? slash + 1 : path, 0); + return vfs_unlink_atfd(dfd, slash ? slash + 1 : path, 0); } - return do_unlink_at(path); + return vfs_unlink_at(path); } /* opath and npath are both expected to live in the entry's directory (the @@ -2238,7 +2238,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, * through the exclude-aware ownership walk so a symlinked * partial-dir can't delete a file in an excluded subtree. */ vfs.operator_path_resolve = 1; - do_unlink_at(partialptr); + vfs_unlink_at(partialptr); vfs.operator_path_resolve = 0; handle_partial_dir(partialptr, PDIR_DELETE); } diff --git a/receiver.c b/receiver.c index 17efee8c3..5dd7feff8 100644 --- a/receiver.c +++ b/receiver.c @@ -1291,7 +1291,7 @@ int recv_files(int f_in, int f_out, char *local_name) * exclude-aware ownership walk (a symlinked partial-dir * must not delete a file in an excluded subtree). */ vfs.operator_path_resolve = 1; - do_unlink_at(partialptr); + vfs_unlink_at(partialptr); vfs.operator_path_resolve = 0; } handle_partial_dir(partialptr, PDIR_DELETE); @@ -1302,7 +1302,7 @@ int recv_files(int f_in, int f_out, char *local_name) "Unable to create partial-dir for %s -- discarding %s.\n", local_name ? local_name : f_name(file, NULL), recv_ok ? "completed file" : "partial file"); - do_unlink_at(fnametmp); + vfs_unlink_at(fnametmp); recv_ok = -1; } else if (!finish_transfer(partialptr, fnametmp, fnamecmp, NULL, file, recv_ok, !partial_dir)) @@ -1313,7 +1313,7 @@ int recv_files(int f_in, int f_out, char *local_name) } else partialptr = NULL; } else if (!one_inplace) - do_unlink_at(fnametmp); + vfs_unlink_at(fnametmp); cleanup_disable(); diff --git a/rsync.c b/rsync.c index 160cdfdb5..7062fdd2b 100644 --- a/rsync.c +++ b/rsync.c @@ -922,7 +922,7 @@ int finish_transfer(const char *fname, const char *fnametmp, full_fname(fnametmp), fname); if (!partialptr || (ret == -2 && temp_copy_name) || robust_rename(fnametmp, partialptr, NULL, file->mode, file) < 0) - do_unlink_at(fnametmp); + vfs_unlink_at(fnametmp); return 0; } if (ret == 0) { diff --git a/sender.c b/sender.c index e5acf5008..3eae87521 100644 --- a/sender.c +++ b/sender.c @@ -418,7 +418,7 @@ void successful_send(int ndx) return; } - if (dfd >= 0 ? secure_remove_source_file(dfd, bname) < 0 : do_unlink(fname) < 0) { + if (dfd >= 0 ? secure_remove_source_file(dfd, bname) < 0 : vfs_unlink(fname) < 0) { failed_op = "remove"; failed: if (errno == ENOENT) diff --git a/syscall.c b/syscall.c index 865a2ecae..8505804b4 100644 --- a/syscall.c +++ b/syscall.c @@ -68,84 +68,6 @@ struct create_time { #endif -int do_unlink(const char *path) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return unlink(path); -} - -/* - Symlink-race-safe variant of do_unlink() for receiver-side use. See - the comment on do_chmod_at() for the threat model. unlink() resolves - parent components, so a parent-symlink swap can delete an outside - file under the daemon's authority. Defence: open the parent of path - under vfs_resolve_open() and use unlinkat() (flags=0) against - that dirfd. - - Falls through to do_unlink() for the same dry-run / non-daemon / - chrooted / no-parent / absolute-path cases as the other wrappers. -*/ -int do_unlink_at(const char *path) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return unlink(path); - dfd = vfs_owner_walk_parent(path, &bname); - if (dfd < 0) - return -1; - ret = unlinkat(dfd, bname, 0); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return unlink(path); - - if (!path || !*path || *path == '/') - return unlink(path); - - slash = strrchr(path, '/'); - if (!slash) - return unlink(path); - - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = unlinkat(dfd, bname, 0); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_unlink(path); -#endif -} #ifdef SUPPORT_LINKS int do_symlink(const char *lnk, const char *path) @@ -794,78 +716,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) #endif } -int do_rmdir(const char *pathname) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return rmdir(pathname); -} - -/* - Symlink-race-safe variant of do_rmdir(). See do_unlink_at() above; - same shape but with AT_REMOVEDIR set to require the target be a - directory. -*/ -int do_rmdir_at(const char *pathname) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(pathname); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return do_rmdir(pathname); - dfd = owner_walk_parent(pathname, &bname); - if (dfd < 0) - return -1; - ret = unlinkat(dfd, bname, AT_REMOVEDIR); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return rmdir(pathname); - - if (!pathname || !*pathname || *pathname == '/') - return rmdir(pathname); - - slash = strrchr(pathname, '/'); - if (!slash) - return rmdir(pathname); - - dlen = slash - pathname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, pathname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = unlinkat(dfd, bname, AT_REMOVEDIR); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_rmdir(pathname); -#endif -} int do_open(const char *pathname, int flags, mode_t mode) { @@ -2012,18 +1862,6 @@ int do_open_checklinks(const char *pathname) } -int do_unlink_atfd(int dfd, const char *name, int flags) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return unlinkat(dfd, name, flags); -#else - (void)dfd; (void)name; (void)flags; - errno = ENOSYS; - return -1; -#endif -} int do_mkdir_atfd(int dfd, const char *name, mode_t mode) { diff --git a/util1.c b/util1.c index e8ca289e9..6a52bd9fa 100644 --- a/util1.c +++ b/util1.c @@ -357,7 +357,7 @@ static int unlink_and_reopen(const char *dest, mode_t mode) /* Use do_open_at so the create/truncate goes through a secure * parent dirfd in the daemon-no-chroot deployment. Otherwise * an attacker could swap a parent component with a symlink in - * the window between robust_unlink (which uses do_unlink_at, + * the window between robust_unlink (which uses vfs_unlink_at, * already secure) and the create here, and redirect the new * file outside the module. */ if ((ofd = do_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { @@ -518,13 +518,13 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) int robust_unlink(const char *fname) { #ifndef ETXTBSY - return do_unlink_at(fname); + return vfs_unlink_at(fname); #else static int counter = 1; int rc, pos, start; char path[MAXPATHLEN]; - rc = do_unlink_at(fname); + rc = vfs_unlink_at(fname); if (rc == 0 || errno != ETXTBSY) return rc; @@ -624,7 +624,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, return -2; if (*from == '/') operator_path_resolve = 1; - do_unlink_at(from); + vfs_unlink_at(from); operator_path_resolve = save; return 1; } @@ -1493,7 +1493,7 @@ int handle_partial_dir(const char *fname, int create) STRUCT_STAT st; int statret = vfs_lstat_at(dir, &st); if (statret == 0 && !S_ISDIR(st.st_mode)) { - if (do_unlink_at(dir) < 0) { + if (vfs_unlink_at(dir) < 0) { vfs.operator_path_resolve = 0; *fn = '/'; return 0; @@ -1506,7 +1506,7 @@ int handle_partial_dir(const char *fname, int create) return 0; } } else - do_rmdir_at(dir); + vfs_rmdir_at(dir); vfs.operator_path_resolve = 0; *fn = '/'; diff --git a/vfs/unlink.c b/vfs/unlink.c new file mode 100644 index 000000000..2de6beb89 --- /dev/null +++ b/vfs/unlink.c @@ -0,0 +1,167 @@ +/* + * vfs/unlink.c - unlink and rmdir wrappers (path, parent-resolved, held-dirfd). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +int vfs_unlink(const char *path) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return unlink(path); +} + +/* + Symlink-race-safe variant of vfs_unlink() for receiver-side use. See + the comment on do_chmod_at() for the threat model. unlink() resolves + parent components, so a parent-symlink swap can delete an outside + file under the daemon's authority. Defence: open the parent of path + under vfs_resolve_open() and use unlinkat() (flags=0) against + that dirfd. + + Falls through to vfs_unlink() for the same dry-run / non-daemon / + chrooted / no-parent / absolute-path cases as the other wrappers. +*/ +int vfs_unlink_at(const char *path) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return unlink(path); + dfd = vfs_owner_walk_parent(path, &bname); + if (dfd < 0) + return -1; + ret = unlinkat(dfd, bname, 0); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + + if (!vfs_relpath_active()) + return unlink(path); + + if (!path || !*path || *path == '/') + return unlink(path); + + slash = strrchr(path, '/'); + if (!slash) + return unlink(path); + + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = unlinkat(dfd, bname, 0); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_unlink(path); +#endif +} + +int vfs_rmdir(const char *pathname) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return rmdir(pathname); +} + +/* + Symlink-race-safe variant of vfs_rmdir(). See vfs_unlink_at() above; + same shape but with AT_REMOVEDIR set to require the target be a + directory. +*/ +int vfs_rmdir_at(const char *pathname) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(pathname); + + if (!vfs_relpath_active()) + return rmdir(pathname); + + if (!pathname || !*pathname || *pathname == '/') + return rmdir(pathname); + + slash = strrchr(pathname, '/'); + if (!slash) + return rmdir(pathname); + + dlen = slash - pathname; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, pathname, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = unlinkat(dfd, bname, AT_REMOVEDIR); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_rmdir(pathname); +#endif +} + +int vfs_unlink_atfd(int dfd, const char *name, int flags) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return unlinkat(dfd, name, flags); +#else + (void)dfd; (void)name; (void)flags; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 9d9a386b8..61510701a 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -88,4 +88,11 @@ int vfs_rename(const char *old_path, const char *new_path); int vfs_rename_at(const char *old_path, const char *new_path); int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name); +/* unlink and rmdir (vfs/unlink.c). */ +int vfs_unlink(const char *path); +int vfs_unlink_at(const char *path); +int vfs_unlink_atfd(int dfd, const char *name, int flags); +int vfs_rmdir(const char *pathname); +int vfs_rmdir_at(const char *pathname); + #endif /* RSYNC_VFS_H */ From e84b935d3b17ff53597f1c5dfecdd05a9c7944e8 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:55:27 +1000 Subject: [PATCH 13/69] vfs: move the open family into vfs/open.c Relocate do_open/do_open_at/do_open_atfd/do_open_nofollow/ do_open_checklinks out of syscall.c into vfs/open.c as the vfs_* names, declared in vfs/vfs.h. Function bodies unchanged; no behavior change. --- Makefile.in | 2 +- batch.c | 2 +- checksum.c | 2 +- clientserver.c | 6 +- flist.c | 2 +- generator.c | 6 +- receiver.c | 14 ++-- sender.c | 4 +- syscall.c | 188 +-------------------------------------------- util1.c | 10 +-- vfs/open.c | 205 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 7 ++ 12 files changed, 238 insertions(+), 210 deletions(-) create mode 100644 vfs/open.c diff --git a/Makefile.in b/Makefile.in index 863917b64..179656d80 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/batch.c b/batch.c index 8a8be1f23..1ac5c0947 100644 --- a/batch.c +++ b/batch.c @@ -259,7 +259,7 @@ void open_batch_files(void) /* O_BINARY: the batch stream is binary protocol data; without it * Cygwin et al apply CRLF translation and corrupt it. Unlike - * do_open(), vfs_open_owner_walk passes flags verbatim. */ + * vfs_open(), vfs_open_owner_walk passes flags verbatim. */ batch_fd = vfs_open_owner_walk(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR); } else if (strcmp(batch_name, "-") == 0) batch_fd = STDIN_FILENO; diff --git a/checksum.c b/checksum.c index 4c91c2b2b..def427ad1 100644 --- a/checksum.c +++ b/checksum.c @@ -423,7 +423,7 @@ void file_checksum(const char *fname, const STRUCT_STAT *st_p, char *sum) int32 remainder; int fd; - fd = do_open_checklinks(fname); + fd = vfs_open_checklinks(fname); if (fd == -1) { memset(sum, 0, file_sum_len); return; diff --git a/clientserver.c b/clientserver.c index 80ecd07c4..cb4e9913d 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1618,18 +1618,18 @@ static void create_pid_file(void) dir = dirbuf; base = slash + 1; } - if ((pdfd = do_open(dir, O_RDONLY|O_DIRECTORY, 0)) < 0) { + if ((pdfd = vfs_open(dir, O_RDONLY|O_DIRECTORY, 0)) < 0) { rsyserr(FLOG, errno, "failed to open pid-file directory \"%s\"", dir); exit_cleanup(RERR_FILEIO); } } #define PID_LSTAT(stp) vfs_lstat_atfd(pdfd, base, stp) #define PID_UNLINK() vfs_unlink_atfd(pdfd, base, 0) -#define PID_OPEN() do_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) +#define PID_OPEN() vfs_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) #else #define PID_LSTAT(stp) vfs_lstat(base, stp) #define PID_UNLINK() unlink(base) -#define PID_OPEN() do_open(base, O_RDWR|O_CREAT|SAFE_NOFOLLOW, 0664) +#define PID_OPEN() vfs_open(base, O_RDWR|O_CREAT|SAFE_NOFOLLOW, 0664) #endif /* These tests make sure that a temp-style lock dir is handled safely. */ diff --git a/flist.c b/flist.c index 1cf3c0ef9..6a8e916d2 100644 --- a/flist.c +++ b/flist.c @@ -1562,7 +1562,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, if (copy_devices && am_sender && IS_DEVICE(st.st_mode)) { if (st.st_size == 0) { - int fd = do_open_checklinks(fname); + int fd = vfs_open_checklinks(fname); if (fd >= 0) { st.st_size = get_device_size(fd, fname); close(fd); diff --git a/generator.c b/generator.c index 478d12354..672869b13 100644 --- a/generator.c +++ b/generator.c @@ -2224,7 +2224,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (write_devices && IS_DEVICE(sx.st.st_mode) && sx.st.st_size == 0) { /* This early open into fd skips the regular open below. */ - if ((fd = do_open_nofollow(fnamecmp, O_RDONLY)) >= 0) + if ((fd = vfs_open_nofollow(fnamecmp, O_RDONLY)) >= 0) real_sx.st.st_size = sx.st.st_size = get_device_size(fd, fnamecmp); } @@ -2309,7 +2309,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } /* open the file */ - if (fd < 0 && (fd = do_open_checklinks(fnamecmp)) < 0) { + if (fd < 0 && (fd = vfs_open_checklinks(fnamecmp)) < 0) { rsyserr(FERROR, errno, "failed to open %s, continuing", full_fname(fnamecmp)); pretend_missing: @@ -2344,7 +2344,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, back_file = NULL; goto cleanup; } - if ((f_copy = do_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { + if ((f_copy = vfs_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); unmake_file(back_file); diff --git a/receiver.c b/receiver.c index 5dd7feff8..219fa6c70 100644 --- a/receiver.c +++ b/receiver.c @@ -140,9 +140,9 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags errno = ENAMETOOLONG; return -1; } - return do_open(fullpath, flags, mode); + return vfs_open(fullpath, flags, mode); } - return do_open(relpath, flags, mode); + return vfs_open(relpath, flags, mode); } if (!basedir && relpath && *relpath == '/') { @@ -1052,7 +1052,7 @@ int recv_files(int f_in, int f_out, char *local_name) const char *slash; assert(fnamecmp != NULL); /* set on every path above */ slash = strrchr(fnamecmp, '/'); - fd1 = do_open_atfd(bdfd, slash ? slash + 1 : fnamecmp, O_RDONLY, 0); + fd1 = vfs_open_atfd(bdfd, slash ? slash + 1 : fnamecmp, O_RDONLY, 0); } else { /* A --partial-dir basis is an operator/peer path: resolve it with * the exclude-aware ownership walk so a symlinked partial-dir @@ -1084,7 +1084,7 @@ int recv_files(int f_in, int f_out, char *local_name) if (fnamecmp != fname) { fnamecmp = fname; fnamecmp_type = FNAMECMP_FNAME; - fd1 = do_open_nofollow(fnamecmp, O_RDONLY); + fd1 = vfs_open_nofollow(fnamecmp, O_RDONLY); } if (fd1 == -1 && basis_dir[0]) { @@ -1180,7 +1180,7 @@ int recv_files(int f_in, int f_out, char *local_name) if (vfs_relpath_active()) fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); else - fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); + fd2 = vfs_open(fnametmp, O_WRONLY|O_CREAT, 0600); vfs.operator_path_resolve = 0; #ifdef linux if (fd2 == -1 && errno == EACCES) { @@ -1188,7 +1188,7 @@ int recv_files(int f_in, int f_out, char *local_name) if (use_secure_symlinks) fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); else - fd2 = do_open(fnametmp, O_WRONLY, 0600); + fd2 = vfs_open(fnametmp, O_WRONLY, 0600); } #endif if (fd2 == -1 && errno == EACCES) { @@ -1216,7 +1216,7 @@ int recv_files(int f_in, int f_out, char *local_name) if (use_secure_symlinks) fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); else - fd2 = do_open(fnametmp, O_WRONLY, 0600); + fd2 = vfs_open(fnametmp, O_WRONLY, 0600); } else errno = errno_save; } diff --git a/sender.c b/sender.c index 3eae87521..40ff96b1d 100644 --- a/sender.c +++ b/sender.c @@ -620,7 +620,7 @@ void send_files(int f_in, int f_out) * yes", admin-only) -- or a non-daemon --insecure-links: legacy * unconfined open, restoring the pre-hardening content read * (re-opening the escape for that module; documented). */ - fd = do_open_checklinks(fname); + fd = vfs_open_checklinks(fname); } else if (vfs_relpath_active()) { /* Open from module root to prevent TOCTOU race where * change_pathname's chdir follows a directory symlink. @@ -672,7 +672,7 @@ void send_files(int f_in, int f_out) } else fd = sender_open_confined(NULL, fname, O_RDONLY); } else { - fd = do_open_checklinks(fname); + fd = vfs_open_checklinks(fname); } if (fd == -1) { if (errno == ENOENT) { diff --git a/syscall.c b/syscall.c index 8505804b4..e22259839 100644 --- a/syscall.c +++ b/syscall.c @@ -213,7 +213,7 @@ ssize_t do_readlink(const char *path, char *buf, size_t bufsiz) { /* For --fake-super, we read the link from the file. */ if (am_root < 0) { - int fd = do_open_nofollow(path, O_RDONLY); + int fd = vfs_open_nofollow(path, O_RDONLY); if (fd >= 0) { int len = read(fd, buf, bufsiz); close(fd); @@ -717,105 +717,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) } -int do_open(const char *pathname, int flags, mode_t mode) -{ - RETURN_ERROR_IF_NULL(pathname); - if (flags != O_RDONLY) { - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF_RO_OR_LO; - } - -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - - return open(pathname, flags | O_BINARY, mode); -} - -/* - Symlink-race-safe variant of do_open() for receiver-side use. See - the comment on do_chmod_at() for the threat model. open() resolves - parent components, so a parent-symlink swap can redirect the open - to a file outside the module. This wrapper is defence-in-depth for - bare-path do_open() sites that callers know are otherwise - protected by secure parent-syscalls (e.g. generator.c's in-place - backup creation, where robust_unlink() rejects the symlinked - parent before this open is reached): if any of those upstream - protections is later removed or regresses, the open here still - refuses to escape the module. - - Defence: open the parent of pathname under vfs_resolve_open() - and call openat() against the resulting dirfd with O_NOFOLLOW - (so the basename itself isn't followed if it happens to be a - pre-planted symlink, which is what we want for O_CREAT|O_EXCL). -*/ -int do_open_at(const char *pathname, int flags, mode_t mode) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (flags != O_RDONLY) { - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF_RO_OR_LO; - } - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return do_open(pathname, flags, mode); - dfd = vfs_owner_walk_parent(pathname, &bname); - if (dfd < 0) - return -1; - ret = openat(dfd, bname, flags | O_NOFOLLOW, mode); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return do_open(pathname, flags, mode); - - if (!pathname || !*pathname || *pathname == '/') - return do_open(pathname, flags, mode); - - slash = strrchr(pathname, '/'); - if (!slash) - return do_open(pathname, flags, mode); - - dlen = slash - pathname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, pathname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - - ret = openat(dfd, bname, flags | O_NOFOLLOW | O_BINARY, mode); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_open(pathname, flags, mode); -#endif -} #ifdef HAVE_CHMOD int do_chmod(const char *path, mode_t mode) @@ -1239,7 +1140,7 @@ int do_mkstemp(char *template, mode_t perms) #else if (!mktemp(template)) return -1; - return do_open(template, O_RDWR|O_EXCL|O_CREAT, perms); + return vfs_open(template, O_RDWR|O_EXCL|O_CREAT, perms); #endif } @@ -1634,56 +1535,6 @@ int do_punch_hole(int fd, OFF_T pos, OFF_T len) return 0; } -int do_open_nofollow(const char *pathname, int flags) -{ -#ifndef O_NOFOLLOW - STRUCT_STAT f_st, l_st; -#endif - int fd; - - if (flags != O_RDONLY) { - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF_RO_OR_LO; -#ifndef O_NOFOLLOW - /* This function doesn't support write attempts w/o O_NOFOLLOW. */ - errno = EINVAL; - return -1; -#endif - } - -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - -#ifdef O_NOFOLLOW - fd = open(pathname, flags|O_NOFOLLOW); -#else - if (vfs_lstat(pathname, &l_st) < 0) - return -1; - if (S_ISLNK(l_st.st_mode)) { - errno = ELOOP; - return -1; - } - if ((fd = open(pathname, flags)) < 0) - return fd; - if (vfs_fstat(fd, &f_st) < 0) { - close_and_return_error: - { - int save_errno = errno; - close(fd); - errno = save_errno; - } - return -1; - } - if (l_st.st_dev != f_st.st_dev || l_st.st_ino != f_st.st_ino) { - errno = EINVAL; - goto close_and_return_error; - } -#endif - - return fd; -} /* The logical current directory (maintained by change_dir() in util1.c). * Defined here -- rather than in util1.c -- so the test helpers that link @@ -1843,23 +1694,6 @@ int secure_mkstemp(char *template, mode_t perms, int operator_path) #endif } -/* - varient of do_open/do_open_nofollow which does do_open() if the - copy_links or copy_unsafe_links options are set and does - do_open_nofollow() otherwise - - This is used to prevent a race condition where an attacker could be - switching a file between being a symlink and being a normal file - - The open is always done with O_RDONLY flags - */ -int do_open_checklinks(const char *pathname) -{ - if (copy_links || copy_unsafe_links) { - return do_open(pathname, O_RDONLY, 0); - } - return do_open_nofollow(pathname, O_RDONLY); -} @@ -1982,24 +1816,6 @@ int do_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) } #endif -int do_open_atfd(int dfd, const char *name, int flags, mode_t mode) -{ -#ifdef AT_FDCWD - if (flags != O_RDONLY) { - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF_RO_OR_LO; - } -#ifdef O_NOATIME - if (open_noatime) - flags |= O_NOATIME; -#endif - return openat(dfd, name, flags | O_NOFOLLOW | O_BINARY, mode); -#else - (void)dfd; (void)name; (void)flags; (void)mode; - errno = ENOSYS; - return -1; -#endif -} int do_symlink_atfd(const char *lnk, int dfd, const char *name) { diff --git a/util1.c b/util1.c index 6a52bd9fa..2af6686ca 100644 --- a/util1.c +++ b/util1.c @@ -354,13 +354,13 @@ static int unlink_and_reopen(const char *dest, mode_t mode) mode |= S_IWUSR; #endif mode &= INITACCESSPERMS; - /* Use do_open_at so the create/truncate goes through a secure + /* Use vfs_open_at so the create/truncate goes through a secure * parent dirfd in the daemon-no-chroot deployment. Otherwise * an attacker could swap a parent component with a symlink in * the window between robust_unlink (which uses vfs_unlink_at, * already secure) and the create here, and redirect the new * file outside the module. */ - if ((ofd = do_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { + if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { int save_errno = errno; rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); errno = save_errno; @@ -393,13 +393,13 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) * vfs_resolve_open so a parent-symlink on the source path (e.g. * --copy-dest=cd where cd is a symlink to an outside directory) cannot * redirect the read to a file the attacker should not see. Plain - * do_open_nofollow only refuses a final-component symlink; parents are + * vfs_open_nofollow only refuses a final-component symlink; parents are * still followed. (An absolute source is operator-trusted -- e.g. an - * absolutized basis dir -- and uses do_open_nofollow.) */ + * absolutized basis dir -- and uses vfs_open_nofollow.) */ if (vfs_relpath_active() && source && *source && source[0] != '/') ifd = vfs_resolve_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); else - ifd = do_open_nofollow(source, O_RDONLY); + ifd = vfs_open_nofollow(source, O_RDONLY); if (ifd < 0) { int save_errno = errno; rsyserr(FERROR_XFER, errno, "open %s", full_fname(source)); diff --git a/vfs/open.c b/vfs/open.c new file mode 100644 index 000000000..f4ee21d7e --- /dev/null +++ b/vfs/open.c @@ -0,0 +1,205 @@ +/* + * vfs/open.c - open wrappers (path, parent-resolved, held-dirfd, nofollow). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +int vfs_open(const char *pathname, int flags, mode_t mode) +{ + RETURN_ERROR_IF_NULL(pathname); + if (flags != O_RDONLY) { + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF_RO_OR_LO; + } + +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + + return open(pathname, flags | O_BINARY, mode); +} + +/* + Symlink-race-safe variant of vfs_open() for receiver-side use. See + the comment on do_chmod_at() for the threat model. open() resolves + parent components, so a parent-symlink swap can redirect the open + to a file outside the module. This wrapper is defence-in-depth for + bare-path vfs_open() sites that callers know are otherwise + protected by secure parent-syscalls (e.g. generator.c's in-place + backup creation, where robust_unlink() rejects the symlinked + parent before this open is reached): if any of those upstream + protections is later removed or regresses, the open here still + refuses to escape the module. + + Defence: open the parent of pathname under vfs_resolve_open() + and call openat() against the resulting dirfd with O_NOFOLLOW + (so the basename itself isn't followed if it happens to be a + pre-planted symlink, which is what we want for O_CREAT|O_EXCL). +*/ +int vfs_open_at(const char *pathname, int flags, mode_t mode) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (flags != O_RDONLY) { + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF_RO_OR_LO; + } + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return vfs_open(pathname, flags, mode); + dfd = vfs_owner_walk_parent(pathname, &bname); + if (dfd < 0) + return -1; + ret = openat(dfd, bname, flags | O_NOFOLLOW, mode); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + + if (!vfs_relpath_active()) + return vfs_open(pathname, flags, mode); + + if (!pathname || !*pathname || *pathname == '/') + return vfs_open(pathname, flags, mode); + + slash = strrchr(pathname, '/'); + if (!slash) + return vfs_open(pathname, flags, mode); + + dlen = slash - pathname; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, pathname, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + + ret = openat(dfd, bname, flags | O_NOFOLLOW | O_BINARY, mode); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_open(pathname, flags, mode); +#endif +} + +int vfs_open_nofollow(const char *pathname, int flags) +{ +#ifndef O_NOFOLLOW + STRUCT_STAT f_st, l_st; +#endif + int fd; + + if (flags != O_RDONLY) { + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF_RO_OR_LO; +#ifndef O_NOFOLLOW + /* This function doesn't support write attempts w/o O_NOFOLLOW. */ + errno = EINVAL; + return -1; +#endif + } + +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + +#ifdef O_NOFOLLOW + fd = open(pathname, flags|O_NOFOLLOW); +#else + if (vfs_lstat(pathname, &l_st) < 0) + return -1; + if (S_ISLNK(l_st.st_mode)) { + errno = ELOOP; + return -1; + } + if ((fd = open(pathname, flags)) < 0) + return fd; + if (vfs_fstat(fd, &f_st) < 0) { + close_and_return_error: + { + int save_errno = errno; + close(fd); + errno = save_errno; + } + return -1; + } + if (l_st.st_dev != f_st.st_dev || l_st.st_ino != f_st.st_ino) { + errno = EINVAL; + goto close_and_return_error; + } +#endif + + return fd; +} + +/* + varient of vfs_open/vfs_open_nofollow which does vfs_open() if the + copy_links or copy_unsafe_links options are set and does + vfs_open_nofollow() otherwise + + This is used to prevent a race condition where an attacker could be + switching a file between being a symlink and being a normal file + + The open is always done with O_RDONLY flags + */ +int vfs_open_checklinks(const char *pathname) +{ + if (copy_links || copy_unsafe_links) { + return vfs_open(pathname, O_RDONLY, 0); + } + return vfs_open_nofollow(pathname, O_RDONLY); +} + +int vfs_open_atfd(int dfd, const char *name, int flags, mode_t mode) +{ +#ifdef AT_FDCWD + if (flags != O_RDONLY) { + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF_RO_OR_LO; + } +#ifdef O_NOATIME + if (open_noatime) + flags |= O_NOATIME; +#endif + return openat(dfd, name, flags | O_NOFOLLOW | O_BINARY, mode); +#else + (void)dfd; (void)name; (void)flags; (void)mode; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 61510701a..9c988ed1d 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -95,4 +95,11 @@ int vfs_unlink_atfd(int dfd, const char *name, int flags); int vfs_rmdir(const char *pathname); int vfs_rmdir_at(const char *pathname); +/* open (vfs/open.c). */ +int vfs_open(const char *pathname, int flags, mode_t mode); +int vfs_open_at(const char *pathname, int flags, mode_t mode); +int vfs_open_atfd(int dfd, const char *name, int flags, mode_t mode); +int vfs_open_nofollow(const char *pathname, int flags); +int vfs_open_checklinks(const char *pathname); + #endif /* RSYNC_VFS_H */ From 07dd81b17b1430bffb6ab30f3ebdc8439a093838 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 17:58:27 +1000 Subject: [PATCH 14/69] vfs: move the chmod family into vfs/chmod.c Relocate do_chmod/do_chmod_at/do_chmod_atfd (and the leaf-safe do_fchmodat_nofollow helper) out of syscall.c into vfs/chmod.c as the vfs_* names, declared in vfs/vfs.h. The HAVE_CHMOD / HAVE_LCHMOD / HAVE_SETATTRLIST / SYS_fchmodat2 platform guards travel verbatim. Function bodies unchanged; no behavior change. (Portability-sensitive family; wants a fleettest with the rest of the #ifdef-heavy moves.) --- Makefile.in | 2 +- delete.c | 4 +- generator.c | 4 +- receiver.c | 4 +- rsync.c | 6 +- syscall.c | 309 ++--------------------------------------------- t_chmod_secure.c | 24 ++-- vfs/chmod.c | 228 ++++++++++++++++++++++++++++++++++ vfs/open.c | 2 +- vfs/rename.c | 2 +- vfs/stat.c | 2 +- vfs/unlink.c | 2 +- vfs/vfs.h | 5 + xattrs.c | 6 +- 14 files changed, 269 insertions(+), 331 deletions(-) create mode 100644 vfs/chmod.c diff --git a/Makefile.in b/Makefile.in index 179656d80..b5b455548 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/delete.c b/delete.c index 47ad4fd88..856a65d3a 100644 --- a/delete.c +++ b/delete.c @@ -63,9 +63,9 @@ static void del_chmod(const char *fbuf, mode_t mode) const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); if (dfd >= 0) - do_chmod_atfd(dfd, leaf, mode); + vfs_chmod_atfd(dfd, leaf, mode); else - do_chmod_at(fbuf, mode); + vfs_chmod_at(fbuf, mode); } static int del_unlink(const char *fbuf) diff --git a/generator.c b/generator.c index 672869b13..f28c927f7 100644 --- a/generator.c +++ b/generator.c @@ -1413,9 +1413,9 @@ static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t m int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); - return do_chmod_atfd(dfd, slash ? slash + 1 : fname, mode); + return vfs_chmod_atfd(dfd, slash ? slash + 1 : fname, mode); } - return do_chmod_at(fname, mode); + return vfs_chmod_at(fname, mode); } static void gen_entry_set_times(const char *fname, struct file_struct *file, STRUCT_STAT *stp) diff --git a/receiver.c b/receiver.c index 219fa6c70..20e5e51af 100644 --- a/receiver.c +++ b/receiver.c @@ -1196,7 +1196,7 @@ int recv_files(int f_in, int f_out, char *local_name) * (its mode is restored after the transfer). On a * non-chroot daemon fchmod() a no-follow fd rather than * chmod the path, so a symlink raced into fnametmp can't - * redirect the chmod (do_chmod_at follows the final link). */ + * redirect the chmod (vfs_chmod_at follows the final link). */ int errno_save = errno, chmod_ok; if (use_secure_symlinks) { #ifdef O_NOFOLLOW @@ -1211,7 +1211,7 @@ int recv_files(int f_in, int f_out, char *local_name) chmod_ok = 0; #endif } else - chmod_ok = do_chmod_at(fnametmp, 0600) == 0; + chmod_ok = vfs_chmod_at(fnametmp, 0600) == 0; if (chmod_ok) { if (use_secure_symlinks) fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); diff --git a/rsync.c b/rsync.c index 7062fdd2b..6b45bf196 100644 --- a/rsync.c +++ b/rsync.c @@ -806,10 +806,8 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, #ifdef HAVE_CHMOD if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { int ret = am_root < 0 ? 0 - : op_leaf_fd >= 0 ? do_fchmod(op_leaf_fd, new_mode) - : op_refuse ? (errno = ELOOP, -1) - : dfd >= 0 && !S_ISLNK(new_mode) ? do_chmod_atfd(dfd, leaf, new_mode) - : do_chmod_at(fname, new_mode); + : dfd >= 0 && !S_ISLNK(new_mode) ? vfs_chmod_atfd(dfd, leaf, new_mode) + : vfs_chmod_at(fname, new_mode); if (ret < 0) { rsyserr(FERROR_XFER, errno, "failed to set permissions on %s", diff --git a/syscall.c b/syscall.c index e22259839..21f7cb57c 100644 --- a/syscall.c +++ b/syscall.c @@ -97,7 +97,7 @@ int do_symlink(const char *lnk, const char *path) /* Symlink-race-safe variant of do_symlink() for receiver-side use. See - the comment on do_chmod_at() for the threat model. For a real symlink + the comment on vfs_chmod_at() for the threat model. For a real symlink only the parent directory of `path` needs protection -- symlinkat() does not resolve the final component (it creates it). Defence: open the parent of `path` under vfs_resolve_open() and call symlinkat() @@ -273,7 +273,7 @@ int do_link(const char *old_path, const char *new_path) /* Symlink-race-safe variant of do_link() for receiver-side use. See - the comment on do_chmod_at() for the threat model. link() resolves + the comment on vfs_chmod_at() for the threat model. link() resolves parent components of *both* old_path and new_path, so a parent- symlink swap on either side can plant the new hard link outside the module, or hard-link an outside file into the module (read @@ -441,7 +441,7 @@ int do_lchown(const char *path, uid_t owner, gid_t group) /* Symlink-race-safe variant of do_lchown() for receiver-side use. See the - comment on do_chmod_at() for the threat model and design rationale. + comment on vfs_chmod_at() for the threat model and design rationale. Resolves the parent directory under vfs_resolve_open() and invokes fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an @@ -451,7 +451,7 @@ int do_lchown(const char *path, uid_t owner, gid_t group) component symlink" semantics. Falls through to do_lchown() in the dry-run / non-daemon / chrooted / - absolute-path / no-parent cases, identical to do_chmod_at(). + absolute-path / no-parent cases, identical to vfs_chmod_at(). */ int do_lchown_at(const char *fname, uid_t owner, gid_t group) { @@ -545,7 +545,7 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) return -1; close(sock); #ifdef HAVE_CHMOD - return do_chmod(pathname, mode); + return vfs_chmod(pathname, mode); #else return 0; #endif @@ -561,7 +561,7 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) /* Symlink-race-safe variant of do_mknod() for receiver-side use. See - the comment on do_chmod_at() for the threat model. Defence: open + the comment on vfs_chmod_at() for the threat model. Defence: open the parent of pathname under vfs_resolve_open() and use mknodat() against that dirfd. mknodat() covers both regular-file (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. @@ -718,281 +718,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) -#ifdef HAVE_CHMOD -int do_chmod(const char *path, mode_t mode) -{ - static int switch_step = 0; - int code; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - - switch (switch_step) { -#ifdef HAVE_LCHMOD - case 0: - if ((code = lchmod(path, mode & CHMOD_BITS)) == 0) - break; - if (errno == ENOSYS) - switch_step++; - else if (errno != ENOTSUP) - break; -#endif - /* FALLTHROUGH */ - default: - if (S_ISLNK(mode)) { -# if defined HAVE_SETATTRLIST - struct attrlist attrList; - uint32_t m = mode & CHMOD_BITS; /* manpage is wrong: not mode_t! */ - - memset(&attrList, 0, sizeof attrList); - attrList.bitmapcount = ATTR_BIT_MAP_COUNT; - attrList.commonattr = ATTR_CMN_ACCESSMASK; - if ((code = setattrlist(path, &attrList, &m, sizeof m, FSOPT_NOFOLLOW)) == 0) - break; - if (errno == ENOTSUP) - code = 1; -# else - code = 1; -# endif - } else - code = chmod(path, mode & CHMOD_BITS); /* DISCOURAGED FUNCTION */ - break; - } - if (code != 0 && (preserve_perms || preserve_executability)) - return code; - return 0; -} - -/* chmod `name` relative to dfd without following a final-component symlink. - * The held parent fd confines the ancestors; this closes the leaf race (an - * attacker swapping the leaf to a symlink that fchmodat(...,0) would follow out - * of the tree). - * - * Never follows the leaf: a regular file or dir is pinned via - * openat(O_NOFOLLOW) and chmod'd with fchmod() (leaf-safe, every kernel, and - * fakeroot-wrappable unlike the raw fchmodat2() syscall); a symlink leaf is - * refused (ELOOP, or EMLINK/EFTYPE on the BSDs). Other types or an open - * failure fall to fchmodat(AT_SYMLINK_NOFOLLOW) (a real no-follow chmod on - * glibc>=2.32 / Linux>=6.6), then the raw fchmodat2() syscall. If no - * no-follow primitive exists we skip with a warning rather than follow the - * leaf. - * - * A FIFO takes the fd path on Linux and the pathname path elsewhere -- see the - * S_ISFIFO arm below for why, and for what that costs. Note the type used to - * choose between them comes from the lstat above, so a leaf swapped between - * that and the open is classified by what it WAS: an observed regular file or - * dir that becomes a FIFO is still opened. O_NOFOLLOW rejects symlinks, not - * type changes. Constraining the open to the observed type would close that; - * it is not done here. */ -static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) -{ -#if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW - mode &= CHMOD_BITS; -# ifdef O_NOFOLLOW - { - STRUCT_STAT st; - if (vfs_lstat_atfd(dfd, name, &st) < 0) - return -1; - if (S_ISLNK(st.st_mode)) { - errno = ELOOP; /* refuse to chmod through a symlink leaf */ - return -1; - } - if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode) || S_ISFIFO(st.st_mode)) { - int fd; -# ifndef __linux__ - /* Never open a FIFO here. Opening one -- even O_NONBLOCK -- - * makes this process a reader for as long as the descriptor - * lives, which wakes a writer blocked in open(O_WRONLY) and - * can cost it a SIGPIPE or the bytes it writes before we - * close. The pathname call reaches the same end state - * without that: it succeeds outright when the mode is - * grantable, and when macOS refuses an ungrantable setgid - * with EPERM (having applied nothing), asking again without - * that bit gives exactly what fchmod() would have -- it drops - * the bit it cannot grant and applies the ordinary ones. - * Measured on macOS: fchmodat(2750) EPERM leaving 0600, - * fchmodat(0750) ok giving 0750, for a FIFO and a directory - * alike. - * - * This is a pathname call, so unlike the descriptor path it - * does not pin the inode; a leaf swapped for another object - * of the same name is chmod'd instead. AT_SYMLINK_NOFOLLOW - * still keeps it off a symlink's target. That trade buys - * away the reader hazard, and only for FIFOs. - * - * Only S_ISGID is retried. An ungrantable S_ISUID would - * still fail where fchmod() would have cleared it, but - * setuid is meaningless on a FIFO and the behaviour is - * undemonstrated, so it is not coded for. - * - * Linux keeps the fd-first order it has always had. */ - if (S_ISFIFO(st.st_mode)) { - if (fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW) == 0) - return 0; - if (errno == EPERM && (mode & S_ISGID) - && fchmodat(dfd, name, mode & ~S_ISGID, - AT_SYMLINK_NOFOLLOW) == 0) - return 0; - return -1; - } -# endif -# ifdef O_CLOEXEC - oflags |= O_CLOEXEC; -# endif - fd = openat(dfd, name, oflags); - if (fd >= 0) { - int r = fchmod(fd, mode), e = errno; - close(fd); - errno = e; - return r; - } - /* A leaf swapped for a symlink between the lstat above and - * this open: refuse rather than fall through. The errno is - * not the same everywhere -- Linux/Solaris ELOOP, FreeBSD - * EMLINK, NetBSD EFTYPE. */ - if (errno == ELOOP -# ifdef EMLINK - || errno == EMLINK -# endif -# ifdef EFTYPE - || errno == EFTYPE -# endif - ) - return -1; /* raced to a symlink: refuse */ - /* otherwise (e.g. EACCES on an unreadable file) fall through */ - } - } -# endif -# if defined __linux__ - { - int r = fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); - if (r == 0) - return 0; - if (errno != ENOTSUP && errno != EOPNOTSUPP && errno != ENOSYS) - return r; /* a real error (EPERM, ENOENT, ...) */ - } -# ifdef SYS_fchmodat2 - { - int r = syscall(SYS_fchmodat2, dfd, name, (unsigned int)mode, AT_SYMLINK_NOFOLLOW); - if (r == 0) - return 0; - if (errno != ENOSYS && errno != EPERM && errno != EOPNOTSUPP) - return r; - } -# endif - /* No symlink-safe chmod primitive here: skip rather than follow the leaf. */ - rprintf(FWARNING, "do_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name); - return 1; -# else - return fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); -# endif -#else - (void)dfd; - (void)mode; - /* No symlink-safe chmod primitive here: skip rather than follow the leaf. */ - rprintf(FWARNING, "do_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name); - return 1; -#endif -} - -/* - Symlink-race-safe variant of do_chmod() for receiver-side use. - - Threat model: on a daemon running with "use chroot = no" (the prerequisite - for CVE-2026-29518), a local attacker can race a symlink swap of one of - the parent directory components of a path the receiver is about to chmod. - Because chmod() resolves symlinks at every component, the swap redirects - the chmod outside the receiver's confinement. - - Defence: open the *parent* directory of fname under vfs_resolve_open() - (a portable per-component O_NOFOLLOW walk on held parent dirfds) and do - fchmodat() against that dirfd. A symlink substituted into one of the parent - components is then either followed within the tree (legitimate dir-symlinks - still work) or rejected (escape attempts fail). - - Final-component handling matches do_chmod(): fchmodat() with flag 0 - follows a symlink at the final component, which is the same behaviour as - chmod() and matches every current call site (the file being chmod'd is - one the receiver itself just created or transferred). For the rare case - where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the - mode bits), we fall through to do_chmod() which has portability code for - that case. - - Falls back to do_chmod() for absolute paths and for paths with no parent - component, where there is nothing to protect against. -*/ -int do_chmod_at(const char *fname, mode_t mode) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path: resolve the parent via the ownership walk, as - * the other do_*_at() wrappers do. Without this the caller's - * operator_path_resolve has no effect here, and an absolute name would - * fall straight through to the unconfined full-path do_chmod(). - * S_ISLNK(mode) still needs do_chmod()'s lchmod()/setattrlist() handling. */ - if (operator_path_resolve && fname && *fname && !S_ISLNK(mode)) { - if (symlink_optout_allowed()) - return do_chmod(fname, mode); - dfd = owner_walk_parent(fname, &bname); - if (dfd < 0) - return -1; - ret = do_fchmodat_nofollow(dfd, bname, mode); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - /* Only the daemon-without-chroot case is exposed to the symlink- - * race attack: a chroot already confines the receiver, and a - * non-daemon rsync runs with the user's own authority so a - * symlink they planted can only redirect to files they could - * already access. Everywhere else, fall through to plain - * do_chmod() to avoid the dirfd-open overhead on every call. */ - if (!vfs_relpath_active()) - return do_chmod(fname, mode); - - if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) - return do_chmod(fname, mode); - - slash = strrchr(fname, '/'); - if (!slash) - return do_chmod(fname, mode); - - dlen = slash - fname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, fname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = do_fchmodat_nofollow(dfd, bname, mode); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_chmod(fname, mode); -#endif -} -#endif #ifdef HAVE_FTRUNCATE @@ -1040,7 +765,7 @@ int do_mkdir(char *path, mode_t mode) /* Symlink-race-safe variant of do_mkdir() for receiver-side use. See - the comment on do_chmod_at() for the threat model and design rationale. + the comment on vfs_chmod_at() for the threat model and design rationale. mkdir() resolves parent symlinks at every component, so a parent- component swap can place an attacker-named directory outside the @@ -1298,7 +1023,7 @@ int do_utimensat(const char *path, STRUCT_STAT *stp) /* Symlink-race-safe variant of do_utimensat() for receiver-side use. - See the comment on do_chmod_at() for the threat model. utimes() + See the comment on vfs_chmod_at() for the threat model. utimes() resolves parent components and follows a final-component symlink; lutimes() doesn't follow the final component but still resolves parents. Either way, a parent-symlink swap can redirect the @@ -1710,24 +1435,6 @@ int do_mkdir_atfd(int dfd, const char *name, mode_t mode) #endif } -#ifdef HAVE_CHMOD -int do_chmod_atfd(int dfd, const char *name, mode_t mode) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - /* Do not follow a final-component symlink (closes the leaf race; the - * held parent dfd already confines the ancestors). A symlink-as-object - * (S_ISLNK(mode)) is still handled by the caller via the full-path - * do_chmod() lchmod/setattrlist code, exactly as do_chmod_at() does. */ - return do_fchmodat_nofollow(dfd, name, mode); -#else - (void)dfd; (void)name; (void)mode; - errno = ENOSYS; - return -1; -#endif -} -#endif int do_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group) { diff --git a/t_chmod_secure.c b/t_chmod_secure.c index bb6d2dd24..a9362e9ef 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -1,7 +1,7 @@ /* - * Test harness for do_chmod_at(). Confirms the symlink-TOCTOU + * Test harness for vfs_chmod_at(). Confirms the symlink-TOCTOU * primitive used by CVE-2026-29518 (and its incomplete-fix follow-up - * for chmod) is closed by do_chmod_at(): a parent directory component + * for chmod) is closed by vfs_chmod_at(): a parent directory component * being a symlink that escapes the receiver's confinement must be * rejected, while a parent symlink that resolves *within* the tree * must still work (so legitimate dir-symlinks are not regressed). @@ -31,7 +31,7 @@ short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; static int errs = 0; -/* Does do_chmod_at()'s leaf handling refuse to follow a symlink at the final +/* Does vfs_chmod_at()'s leaf handling refuse to follow a symlink at the final * component? Yes wherever AT_SYMLINK_NOFOLLOW exists; otherwise the wrapper * falls back to a following fchmodat() (documented limitation). Mirrors the * #ifdef ladder in do_fchmodat_nofollow. */ @@ -85,9 +85,9 @@ int main(int argc, char **argv) return 2; } - /* Simulate the daemon-without-chroot deployment that do_chmod_at() + /* Simulate the daemon-without-chroot deployment that vfs_chmod_at() * defends. With am_daemon=0 or am_chrooted=1 the wrapper falls - * through to plain do_chmod() and the symlink-race test would be + * through to plain vfs_chmod() and the symlink-race test would be * meaningless. */ am_daemon = 1; am_chrooted = 0; @@ -112,26 +112,26 @@ int main(int argc, char **argv) * Solaris, older Cygwin, HPE NonStop, pre-5.6 Linux) -- which now follows * an in-tree directory symlink whose target is relative and ".."-free. * Escapes are still rejected on both paths (Scenario B). */ - int rc = do_chmod_at("inside_link/sentinel", 0640); + int rc = vfs_chmod_at("inside_link/sentinel", 0640); check("A: legit dir-symlink within tree (followed)", rc, 1, "realdir/sentinel", 0640); /* Scenario B: parent symlink escapes the tree -- chmod must be * rejected and the outside file's mode must be unchanged. */ - rc = do_chmod_at("escape_link/sentinel", 0666); + rc = vfs_chmod_at("escape_link/sentinel", 0666); check("B: parent symlink escapes tree (the attack)", rc, 0, "../trap/sentinel", 0600); /* Scenario C: plain relative path with no symlink components, * regression check that the safe wrapper doesn't break the * normal case. */ - rc = do_chmod_at("realdir/sentinel", 0644); + rc = vfs_chmod_at("realdir/sentinel", 0644); check("C: plain relative path (regression check)", rc, 1, "realdir/sentinel", 0644); /* Scenario D: top-level file, no parent directory component. - * Falls back to do_chmod(); should succeed. */ - rc = do_chmod_at("topfile", 0640); + * Falls back to vfs_chmod(); should succeed. */ + rc = vfs_chmod_at("topfile", 0640); check("D: top-level file, no parent component", rc, 1, "topfile", 0640); @@ -141,12 +141,12 @@ int main(int argc, char **argv) * (refused on Linux, lchmod-the-symlink on *BSD/macOS), so assert only that * the outside target's mode is unchanged. */ if (leaf_chmod_nofollow_supported()) { - rc = do_chmod_at("realdir/leaflink", 0666); + rc = vfs_chmod_at("realdir/leaflink", 0666); check("E: leaf component is an escaping symlink (must not be followed)", rc, -1, "../trap/sentinel", 0600); } else { fprintf(stderr, "INFO: leaf-nofollow chmod unsupported here; " - "do_chmod_at follows a leaf symlink (documented limitation), " + "vfs_chmod_at follows a leaf symlink (documented limitation), " "skipping scenario E\n"); } diff --git a/vfs/chmod.c b/vfs/chmod.c new file mode 100644 index 000000000..eb775d407 --- /dev/null +++ b/vfs/chmod.c @@ -0,0 +1,228 @@ +/* + * vfs/chmod.c - chmod wrappers (path, parent-resolved, held-dirfd). + * + * Includes the platform-specific lchmod/setattrlist/SYS_fchmodat2 handling and + * the leaf-safe do_fchmodat_nofollow helper. Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +#ifdef HAVE_CHMOD +int vfs_chmod(const char *path, mode_t mode) +{ + static int switch_step = 0; + int code; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + + switch (switch_step) { +#ifdef HAVE_LCHMOD + case 0: + if ((code = lchmod(path, mode & CHMOD_BITS)) == 0) + break; + if (errno == ENOSYS) + switch_step++; + else if (errno != ENOTSUP) + break; +#endif + /* FALLTHROUGH */ + default: + if (S_ISLNK(mode)) { +# if defined HAVE_SETATTRLIST + struct attrlist attrList; + uint32_t m = mode & CHMOD_BITS; /* manpage is wrong: not mode_t! */ + + memset(&attrList, 0, sizeof attrList); + attrList.bitmapcount = ATTR_BIT_MAP_COUNT; + attrList.commonattr = ATTR_CMN_ACCESSMASK; + if ((code = setattrlist(path, &attrList, &m, sizeof m, FSOPT_NOFOLLOW)) == 0) + break; + if (errno == ENOTSUP) + code = 1; +# else + code = 1; +# endif + } else + code = chmod(path, mode & CHMOD_BITS); /* DISCOURAGED FUNCTION */ + break; + } + if (code != 0 && (preserve_perms || preserve_executability)) + return code; + return 0; +} + +/* chmod `name` relative to dfd without following a final-component symlink. + * The held parent fd confines the ancestors; this closes the leaf race (an + * attacker swapping the leaf to a symlink that fchmodat(...,0) would follow out + * of the tree). + * + * Never follows the leaf: a regular file, dir or FIFO is pinned via + * openat(O_NOFOLLOW) and chmod'd with fchmod() (leaf-safe, every kernel, and + * fakeroot-wrappable unlike the raw fchmodat2() syscall); a symlink leaf is + * refused (ELOOP). Other types or an open failure fall to + * fchmodat(AT_SYMLINK_NOFOLLOW) (a real no-follow chmod on glibc>=2.32 / + * Linux>=6.6), then the raw fchmodat2() syscall. If no no-follow primitive + * exists we skip with a warning rather than follow the leaf. */ +static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) +{ + mode &= CHMOD_BITS; +#if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW +# if defined __linux__ + { + STRUCT_STAT st; + if (vfs_lstat_atfd(dfd, name, &st) < 0) + return -1; + if (S_ISLNK(st.st_mode)) { + errno = ELOOP; /* refuse to chmod through a symlink leaf */ + return -1; + } + if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode) || S_ISFIFO(st.st_mode)) { + int fd = openat(dfd, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC); + if (fd >= 0) { + int r = fchmod(fd, mode), e = errno; + close(fd); + errno = e; + return r; + } + if (errno == ELOOP) + return -1; /* raced to a symlink: refuse */ + /* otherwise (e.g. EACCES on an unreadable file) fall through */ + } + } + { + int r = fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); + if (r == 0) + return 0; + if (errno != ENOTSUP && errno != EOPNOTSUPP && errno != ENOSYS) + return r; /* a real error (EPERM, ENOENT, ...) */ + } +# ifdef SYS_fchmodat2 + { + int r = syscall(SYS_fchmodat2, dfd, name, (unsigned int)mode, AT_SYMLINK_NOFOLLOW); + if (r == 0) + return 0; + if (errno != ENOSYS && errno != EPERM && errno != EOPNOTSUPP) + return r; + } +# endif + /* No symlink-safe chmod primitive here: skip rather than follow the leaf. */ + rprintf(FWARNING, "vfs_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name); + return 1; +# else + return fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); +# endif +#else + /* No symlink-safe chmod primitive here: skip rather than follow the leaf. */ + rprintf(FWARNING, "vfs_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name); + return 1; +#endif +} + +/* + Symlink-race-safe variant of vfs_chmod() for receiver-side use. + + Threat model: on a daemon running with "use chroot = no" (the prerequisite + for CVE-2026-29518), a local attacker can race a symlink swap of one of + the parent directory components of a path the receiver is about to chmod. + Because chmod() resolves symlinks at every component, the swap redirects + the chmod outside the receiver's confinement. + + Defence: open the *parent* directory of fname under vfs_resolve_open() + (a portable per-component O_NOFOLLOW walk on held parent dirfds) and do + fchmodat() against that dirfd. A symlink substituted into one of the parent + components is then either followed within the tree (legitimate dir-symlinks + still work) or rejected (escape attempts fail). + + Final-component handling matches vfs_chmod(): fchmodat() with flag 0 + follows a symlink at the final component, which is the same behaviour as + chmod() and matches every current call site (the file being chmod'd is + one the receiver itself just created or transferred). For the rare case + where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the + mode bits), we fall through to vfs_chmod() which has portability code for + that case. + + Falls back to vfs_chmod() for absolute paths and for paths with no parent + component, where there is nothing to protect against. +*/ +int vfs_chmod_at(const char *fname, mode_t mode) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + /* Only the daemon-without-chroot case is exposed to the symlink- + * race attack: a chroot already confines the receiver, and a + * non-daemon rsync runs with the user's own authority so a + * symlink they planted can only redirect to files they could + * already access. Everywhere else, fall through to plain + * vfs_chmod() to avoid the dirfd-open overhead on every call. */ + if (!vfs_relpath_active()) + return vfs_chmod(fname, mode); + + if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) + return vfs_chmod(fname, mode); + + slash = strrchr(fname, '/'); + if (!slash) + return vfs_chmod(fname, mode); + + dlen = slash - fname; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, fname, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = do_fchmodat_nofollow(dfd, bname, mode); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_chmod(fname, mode); +#endif +} +#endif + +#ifdef HAVE_CHMOD +int vfs_chmod_atfd(int dfd, const char *name, mode_t mode) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + /* Do not follow a final-component symlink (closes the leaf race; the + * held parent dfd already confines the ancestors). A symlink-as-object + * (S_ISLNK(mode)) is still handled by the caller via the full-path + * vfs_chmod() lchmod/setattrlist code, exactly as vfs_chmod_at() does. */ + return do_fchmodat_nofollow(dfd, name, mode); +#else + (void)dfd; (void)name; (void)mode; + errno = ENOSYS; + return -1; +#endif +} +#endif diff --git a/vfs/open.c b/vfs/open.c index f4ee21d7e..b4fbfe7c1 100644 --- a/vfs/open.c +++ b/vfs/open.c @@ -34,7 +34,7 @@ int vfs_open(const char *pathname, int flags, mode_t mode) /* Symlink-race-safe variant of vfs_open() for receiver-side use. See - the comment on do_chmod_at() for the threat model. open() resolves + the comment on vfs_chmod_at() for the threat model. open() resolves parent components, so a parent-symlink swap can redirect the open to a file outside the module. This wrapper is defence-in-depth for bare-path vfs_open() sites that callers know are otherwise diff --git a/vfs/rename.c b/vfs/rename.c index bd93e0dbc..ee97e4085 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -25,7 +25,7 @@ int vfs_rename(const char *old_path, const char *new_path) /* Symlink-race-safe variant of vfs_rename() for receiver-side use. See - the comment on do_chmod_at() for the threat model and design rationale. + the comment on vfs_chmod_at() for the threat model and design rationale. rename() is the central tmp -> final operation in rsync; if either the source or the destination has an attacker-substituted symlink in one diff --git a/vfs/stat.c b/vfs/stat.c index d5447c2c5..7796b82bf 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -42,7 +42,7 @@ int vfs_lstat(const char *path, STRUCT_STAT *st) /* Symlink-race-safe variants of vfs_stat() / vfs_lstat() for receiver- - side use. See the comment on do_chmod_at() for the threat model. + side use. See the comment on vfs_chmod_at() for the threat model. stat() and lstat() resolve parent components, so a parent-symlink swap can make the receiver's stat see attributes of a victim file outside the module -- which then drives later behaviour (e.g. diff --git a/vfs/unlink.c b/vfs/unlink.c index 2de6beb89..b8e12eb77 100644 --- a/vfs/unlink.c +++ b/vfs/unlink.c @@ -25,7 +25,7 @@ int vfs_unlink(const char *path) /* Symlink-race-safe variant of vfs_unlink() for receiver-side use. See - the comment on do_chmod_at() for the threat model. unlink() resolves + the comment on vfs_chmod_at() for the threat model. unlink() resolves parent components, so a parent-symlink swap can delete an outside file under the daemon's authority. Defence: open the parent of path under vfs_resolve_open() and use unlinkat() (flags=0) against diff --git a/vfs/vfs.h b/vfs/vfs.h index 9c988ed1d..28ddfb95c 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -102,4 +102,9 @@ int vfs_open_atfd(int dfd, const char *name, int flags, mode_t mode); int vfs_open_nofollow(const char *pathname, int flags); int vfs_open_checklinks(const char *pathname); +/* chmod (vfs/chmod.c). */ +int vfs_chmod(const char *path, mode_t mode); +int vfs_chmod_at(const char *fname, mode_t mode); +int vfs_chmod_atfd(int dfd, const char *name, mode_t mode); + #endif /* RSYNC_VFS_H */ diff --git a/xattrs.c b/xattrs.c index 39f5bdac6..0c7cd97a5 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1154,7 +1154,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna #endif && access(fname, W_OK) < 0 && (fd >= 0 ? fchmod(fd, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) - : do_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR)) == 0) + : vfs_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR)) == 0) added_write_perm = 1; ndx = F_XATTR(file); @@ -1177,7 +1177,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna if (fd >= 0) fchmod(fd, sxp->st.st_mode); else - do_chmod_at(fname, sxp->st.st_mode); + vfs_chmod_at(fname, sxp->st.st_mode); } return return_value; } @@ -1317,7 +1317,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode, if (fd >= 0) fchmod(fd, mode); else - do_chmod_at(fname, mode); + vfs_chmod_at(fname, mode); } if (!IS_DEVICE(fst.st_mode)) fst.st_rdev = 0; /* just in case */ From e76b92737834b5212c63e4f55806ddc78c8b2ddb Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:01:29 +1000 Subject: [PATCH 15/69] vfs: move the symlink/readlink family into vfs/symlink.c Relocate do_symlink/do_symlink_at/do_symlink_atfd and do_readlink/ do_readlink_atfd out of syscall.c into vfs/symlink.c as the vfs_* names. The fake-super (NO_SYMLINK_*XATTRS) placeholder handling travels verbatim. vfs_readlink stays a function only in fake-super builds and a macro -> readlink() otherwise (the rsync.h macro is renamed to match); its vfs.h declaration is guarded accordingly. No behavior change. --- Makefile.in | 2 +- backup.c | 2 +- flist.c | 4 +- generator.c | 6 +- rsync.h | 2 +- sender.c | 2 +- syscall.c | 217 ------------------------------------------- t_symlink_secure.c | 20 ++-- tls.c | 4 +- vfs/symlink.c | 223 +++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 10 ++ 11 files changed, 254 insertions(+), 238 deletions(-) create mode 100644 vfs/symlink.c diff --git a/Makefile.in b/Makefile.in index b5b455548..5b0f1a947 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index abc7e77cc..0c245d1de 100644 --- a/backup.c +++ b/backup.c @@ -373,7 +373,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) } ret = 2; } else { - if (do_symlink_at(sl, buf) < 0) + if (vfs_symlink_at(sl, buf) < 0) rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl); else if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname); diff --git a/flist.c b/flist.c index 6a8e916d2..2a7cf7255 100644 --- a/flist.c +++ b/flist.c @@ -248,8 +248,8 @@ static int scan_readlink(const char *path, char *linkbuf, size_t bufsiz) && strncmp(path, scan_dir_prefix, scan_dir_prefix_len) == 0 && path[scan_dir_prefix_len] == '/' && strchr(path + scan_dir_prefix_len + 1, '/') == NULL) - return do_readlink_atfd(scan_dirfd, path + scan_dir_prefix_len + 1, linkbuf, bufsiz); - return do_readlink(path, linkbuf, bufsiz); + return vfs_readlink_atfd(scan_dirfd, path + scan_dir_prefix_len + 1, linkbuf, bufsiz); + return vfs_readlink(path, linkbuf, bufsiz); } static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf) diff --git a/generator.c b/generator.c index f28c927f7..42ed2b9a7 100644 --- a/generator.c +++ b/generator.c @@ -655,7 +655,7 @@ int quick_check_ok(enum filetype ftype, const char *fn, struct file_struct *file case FT_SYMLINK: { #ifdef SUPPORT_LINKS char lnk[MAXPATHLEN]; - int len = do_readlink(fn, lnk, MAXPATHLEN-1); + int len = vfs_readlink(fn, lnk, MAXPATHLEN-1); if (len <= 0) return 0; lnk[len] = '\0'; @@ -1434,9 +1434,9 @@ static int gen_entry_symlink(const char *slnk, const char *path, struct file_str int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); - return do_symlink_atfd(slnk, dfd, slash ? slash + 1 : path); + return vfs_symlink_atfd(slnk, dfd, slash ? slash + 1 : path); } - return do_symlink_at(slnk, path); + return vfs_symlink_at(slnk, path); } /* True when this build compiled no fd-relative primitive able to create this diff --git a/rsync.h b/rsync.h index e6c5bb1af..8fefdaff7 100644 --- a/rsync.h +++ b/rsync.h @@ -1282,7 +1282,7 @@ extern int errno; #ifdef HAVE_READLINK #define SUPPORT_LINKS 1 #if !defined NO_SYMLINK_XATTRS && !defined NO_SYMLINK_USER_XATTRS -#define do_readlink(path, buf, bufsiz) readlink(path, buf, bufsiz) +#define vfs_readlink(path, buf, bufsiz) readlink(path, buf, bufsiz) #endif #endif #ifdef HAVE_LINK diff --git a/sender.c b/sender.c index 40ff96b1d..fd0c350ab 100644 --- a/sender.c +++ b/sender.c @@ -267,7 +267,7 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat } if ((pdfd = vfs_resolve_open(anchor, dir, O_RDONLY | O_DIRECTORY, 0)) < 0) return -1; - n = do_readlink_atfd(pdfd, bname, tgt, sizeof tgt - 1); + n = vfs_readlink_atfd(pdfd, bname, tgt, sizeof tgt - 1); e = errno; if (n < 0) { /* EINVAL: not a symlink -> the resolved target file. Open it diff --git a/syscall.c b/syscall.c index 21f7cb57c..0c2d32636 100644 --- a/syscall.c +++ b/syscall.c @@ -69,193 +69,6 @@ struct create_time { -#ifdef SUPPORT_LINKS -int do_symlink(const char *lnk, const char *path) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(lnk); - RETURN_ERROR_IF_NULL(path); - -#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS - /* For --fake-super, we create a normal file with mode 0600 - * and write the lnk into it. */ - if (am_root < 0) { - int ok, len = strlen(lnk); - int fd = open(path, O_WRONLY|O_CREAT|O_TRUNC, S_IWUSR|S_IRUSR); - if (fd < 0) - return -1; - ok = write(fd, lnk, len) == len; - if (close(fd) < 0) - ok = 0; - return ok ? 0 : -1; - } -#endif - - return symlink(lnk, path); -} - -/* - Symlink-race-safe variant of do_symlink() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. For a real symlink - only the parent directory of `path` needs protection -- symlinkat() - does not resolve the final component (it creates it). Defence: open - the parent of `path` under vfs_resolve_open() and call symlinkat() - against that dirfd; a top-level (no-slash) path has no parent to - confine, so it uses AT_FDCWD directly. The link target string `lnk` is - stored verbatim and not resolved at creation time, so it doesn't need - scrutiny here. - - For --fake-super (am_root < 0) the "symlink" is written as a regular - file, so the final component IS resolved at creation: we create it - with openat(... O_NOFOLLOW) so a pre-planted symlink at the basename - cannot redirect the write outside the module. This protection applies - to top-level paths too -- the previous code fell through to the - bare-path do_symlink() there, whose plain open() followed such a - symlink. -*/ -int do_symlink_at(const char *lnk, const char *path) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd = AT_FDCWD, ret, e; - BOOL owns = False; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (operator_path_resolve) { - /* Operator path (e.g. an absolute --backup-dir): confine the - * parent with the ownership walk, then fall through to the shared - * leaf-creation below so fake-super emulation is preserved. */ - if (vfs_symlink_optout_allowed()) - return do_symlink(lnk, path); - dfd = owner_walk_parent(path, &bname); - if (dfd < 0) - return -1; - owns = True; - } else -#endif - { - if (!vfs_relpath_active()) - return do_symlink(lnk, path); - - if (!path || !*path || *path == '/') - return do_symlink(lnk, path); - - /* A path with a slash needs vfs_resolve_open to confine its - * parent; a top-level path is in CWD (AT_FDCWD), no parent to - * subvert. The leaf is protected below either way (symlinkat() - * won't follow it; the fake-super openat() uses O_NOFOLLOW). */ - slash = strrchr(path, '/'); - if (slash) { - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - owns = True; - } else { - bname = path; - } - } - -#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS - /* For --fake-super, do_symlink writes the link target into a - * regular file rather than creating a real symlink. Do that here - * against the (secure or AT_FDCWD) dirfd, with O_NOFOLLOW so a pre- - * planted symlink at the basename can't redirect the file creation. */ - if (am_root < 0) { - int len = strlen(lnk); - int fd = openat(dfd, bname, - O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, - S_IWUSR | S_IRUSR); - if (fd < 0) { - e = errno; - if (owns) close(dfd); - errno = e; - return -1; - } - ret = (write(fd, lnk, len) == len) ? 0 : -1; - if (close(fd) < 0) - ret = -1; - e = errno; - if (owns) close(dfd); - errno = e; - return ret; - } -#endif - - ret = symlinkat(lnk, dfd, bname); - e = errno; - if (owns) close(dfd); - errno = e; - return ret; -#else - return do_symlink(lnk, path); -#endif -} - -/* NOFOLLOW_HIT_SYMLINK() lives in rsync.h (shared with util1.c's change_dir). */ - -#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS -ssize_t do_readlink(const char *path, char *buf, size_t bufsiz) -{ - /* For --fake-super, we read the link from the file. */ - if (am_root < 0) { - int fd = vfs_open_nofollow(path, O_RDONLY); - if (fd >= 0) { - int len = read(fd, buf, bufsiz); - close(fd); - return len; - } - if (!NOFOLLOW_HIT_SYMLINK(errno)) - return -1; - /* A real symlink needs to be turned into a fake one on the receiving - * side, so tell the generator that the link has no length. */ - if (!am_sender) - return 0; - /* Otherwise fall through and let the sender report the real length. */ - } - - return readlink(path, buf, bufsiz); -} -#endif - -ssize_t do_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz) -{ -#ifdef AT_FDCWD -# if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS - if (am_root < 0) { - int fd = openat(dfd, name, O_RDONLY | O_NOFOLLOW); - if (fd >= 0) { - int len = read(fd, buf, bufsiz); - close(fd); - return len; - } - if (!NOFOLLOW_HIT_SYMLINK(errno)) - return -1; - if (!am_sender) - return 0; - } -# endif - return readlinkat(dfd, name, buf, bufsiz); -#else - (void)dfd; - return do_readlink(name, buf, bufsiz); -#endif -} -#endif #if defined HAVE_LINK || defined HAVE_LINKAT int do_link(const char *old_path, const char *new_path) @@ -1524,36 +1337,6 @@ int do_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) #endif -int do_symlink_atfd(const char *lnk, int dfd, const char *name) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - -#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS - /* --fake-super: store the link target in a regular placeholder file, - * created with O_NOFOLLOW so a planted basename symlink can't redirect - * the write (mirrors do_symlink_at()). */ - if (am_root < 0) { - int len = strlen(lnk); - int ok; - int fd = openat(dfd, name, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, - S_IWUSR | S_IRUSR); - if (fd < 0) - return -1; - ok = write(fd, lnk, len) == len; - if (close(fd) < 0) - ok = 0; - return ok ? 0 : -1; - } -#endif - return symlinkat(lnk, dfd, name); -#else - (void)lnk; (void)dfd; (void)name; - errno = ENOSYS; - return -1; -#endif -} int do_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) { diff --git a/t_symlink_secure.c b/t_symlink_secure.c index 64eae888a..e30cf219d 100644 --- a/t_symlink_secure.c +++ b/t_symlink_secure.c @@ -1,8 +1,8 @@ /* - * Test harness for the fake-super branches of do_symlink_at()/do_mknod_at(). + * Test harness for the fake-super branches of vfs_symlink_at()/do_mknod_at(). * Fake-super stores a symlink/device as a placeholder file, so the create * resolves the final component; the no-slash branch used to fall back to - * do_symlink()/do_mknod(), whose plain open() followed a planted basename + * vfs_symlink()/do_mknod(), whose plain open() followed a planted basename * symlink and escaped the module. Checks the fixed wrappers refuse it; * --poc shows the old fallback escaping. Not linked into rsync. GPL version 2. */ @@ -12,7 +12,7 @@ #include /* The symlink placeholder (and thus this escape) exists only where symlink - * xattrs are unavailable -- the same guard do_symlink() uses. Elsewhere + * xattrs are unavailable -- the same guard vfs_symlink() uses. Elsewhere * symlink() fails EEXIST on a planted link, so only the device path applies. */ #if defined SUPPORT_LINKS && (defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS) #define TEST_SYMLINK_PLACEHOLDER 1 @@ -118,11 +118,11 @@ int main(int argc, char **argv) am_root = -1; /* fake-super: symlinks/devices stored as files */ if (poc) { - /* Pre-fix fallback: a no-slash path went to do_symlink()/do_mknod(), + /* Pre-fix fallback: a no-slash path went to vfs_symlink()/do_mknod(), * which open() the basename without O_NOFOLLOW. */ #ifdef TEST_SYMLINK_PLACEHOLDER - do_symlink("VULN_SYM_PAYLOAD", "sympath"); - check_clobbered("poc do_symlink bare", "../outside/secret_sym", + vfs_symlink("VULN_SYM_PAYLOAD", "sympath"); + check_clobbered("poc vfs_symlink bare", "../outside/secret_sym", "VULN_SYM_PAYLOAD"); #endif do_mknod("nodpath", S_IFCHR | 0600, 0); @@ -133,12 +133,12 @@ int main(int argc, char **argv) /* Fixed wrappers: a bare-path basename symlink must not be followed; * the victim outside the module stays untouched. */ #ifdef TEST_SYMLINK_PLACEHOLDER - do_symlink_at("FIXED_SYM_PAYLOAD", "sympath"); - check_preserved("do_symlink_at bare", "../outside/secret_sym", "VICTIM_SYM"); + vfs_symlink_at("FIXED_SYM_PAYLOAD", "sympath"); + check_preserved("vfs_symlink_at bare", "../outside/secret_sym", "VICTIM_SYM"); /* Slashed path for parity (already protected before the fix). */ - do_symlink_at("FIXED_SYM_PAYLOAD", "sub/sympath2"); - check_preserved("do_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2"); + vfs_symlink_at("FIXED_SYM_PAYLOAD", "sub/sympath2"); + check_preserved("vfs_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2"); #endif # ifdef HAVE_MKNODAT diff --git a/tls.c b/tls.c index b7dfac7ea..5aff8ecac 100644 --- a/tls.c +++ b/tls.c @@ -188,9 +188,9 @@ static void list_file(const char *fname) buf.st_uid = buf.st_gid = 0; strlcpy(linkbuf, " -> ", sizeof linkbuf); /* const-cast required for silly UNICOS headers */ - len = do_readlink((char*)fname, linkbuf+4, sizeof linkbuf - 4); + len = vfs_readlink((char*)fname, linkbuf+4, sizeof linkbuf - 4); if (len == -1) - failed("do_readlink", fname); + failed("vfs_readlink", fname); else /* it's not nul-terminated */ linkbuf[4+len] = 0; diff --git a/vfs/symlink.c b/vfs/symlink.c new file mode 100644 index 000000000..7c87b42ad --- /dev/null +++ b/vfs/symlink.c @@ -0,0 +1,223 @@ +/* + * vfs/symlink.c - symlink and readlink wrappers. + * + * Includes the fake-super (NO_SYMLINK_*XATTRS) placeholder-file handling that + * stands in for real symlinks when the receiver can't create them. Moved + * verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +#ifdef SUPPORT_LINKS +int vfs_symlink(const char *lnk, const char *path) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(lnk); + RETURN_ERROR_IF_NULL(path); + +#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + /* For --fake-super, we create a normal file with mode 0600 + * and write the lnk into it. */ + if (am_root < 0) { + int ok, len = strlen(lnk); + int fd = open(path, O_WRONLY|O_CREAT|O_TRUNC, S_IWUSR|S_IRUSR); + if (fd < 0) + return -1; + ok = write(fd, lnk, len) == len; + if (close(fd) < 0) + ok = 0; + return ok ? 0 : -1; + } +#endif + + return symlink(lnk, path); +} + +/* + Symlink-race-safe variant of vfs_symlink() for receiver-side use. See + the comment on vfs_chmod_at() for the threat model. For a real symlink + only the parent directory of `path` needs protection -- symlinkat() + does not resolve the final component (it creates it). Defence: open + the parent of `path` under vfs_resolve_open() and call symlinkat() + against that dirfd; a top-level (no-slash) path has no parent to + confine, so it uses AT_FDCWD directly. The link target string `lnk` is + stored verbatim and not resolved at creation time, so it doesn't need + scrutiny here. + + For --fake-super (am_root < 0) the "symlink" is written as a regular + file, so the final component IS resolved at creation: we create it + with openat(... O_NOFOLLOW) so a pre-planted symlink at the basename + cannot redirect the write outside the module. This protection applies + to top-level paths too -- the previous code fell through to the + bare-path vfs_symlink() there, whose plain open() followed such a + symlink. +*/ +int vfs_symlink_at(const char *lnk, const char *path) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd = AT_FDCWD, ret, e; + BOOL owns = False; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (!vfs_relpath_active()) + return vfs_symlink(lnk, path); + + if (!path || !*path || *path == '/') + return vfs_symlink(lnk, path); + + /* A path with a slash needs vfs_resolve_open to confine its parent; + * a top-level path is in CWD (AT_FDCWD), no parent to subvert. The leaf + * is protected below either way (symlinkat() won't follow it; the + * fake-super openat() uses O_NOFOLLOW). */ + slash = strrchr(path, '/'); + if (slash) { + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + owns = True; + } else { + bname = path; + } + +#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + /* For --fake-super, vfs_symlink writes the link target into a + * regular file rather than creating a real symlink. Do that here + * against the (secure or AT_FDCWD) dirfd, with O_NOFOLLOW so a pre- + * planted symlink at the basename can't redirect the file creation. */ + if (am_root < 0) { + int len = strlen(lnk); + int fd = openat(dfd, bname, + O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IWUSR | S_IRUSR); + if (fd < 0) { + e = errno; + if (owns) close(dfd); + errno = e; + return -1; + } + ret = (write(fd, lnk, len) == len) ? 0 : -1; + if (close(fd) < 0) + ret = -1; + e = errno; + if (owns) close(dfd); + errno = e; + return ret; + } +#endif + + ret = symlinkat(lnk, dfd, bname); + e = errno; + if (owns) close(dfd); + errno = e; + return ret; +#else + return vfs_symlink(lnk, path); +#endif +} + +/* NOFOLLOW_HIT_SYMLINK() lives in rsync.h (shared with util1.c's change_dir). */ + +#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS +ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz) +{ + /* For --fake-super, we read the link from the file. */ + if (am_root < 0) { + int fd = vfs_open_nofollow(path, O_RDONLY); + if (fd >= 0) { + int len = read(fd, buf, bufsiz); + close(fd); + return len; + } + if (!NOFOLLOW_HIT_SYMLINK(errno)) + return -1; + /* A real symlink needs to be turned into a fake one on the receiving + * side, so tell the generator that the link has no length. */ + if (!am_sender) + return 0; + /* Otherwise fall through and let the sender report the real length. */ + } + + return readlink(path, buf, bufsiz); +} +#endif + +ssize_t vfs_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz) +{ +#ifdef AT_FDCWD +# if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + if (am_root < 0) { + int fd = openat(dfd, name, O_RDONLY | O_NOFOLLOW); + if (fd >= 0) { + int len = read(fd, buf, bufsiz); + close(fd); + return len; + } + if (!NOFOLLOW_HIT_SYMLINK(errno)) + return -1; + if (!am_sender) + return 0; + } +# endif + return readlinkat(dfd, name, buf, bufsiz); +#else + (void)dfd; + return vfs_readlink(name, buf, bufsiz); +#endif +} +#endif + +int vfs_symlink_atfd(const char *lnk, int dfd, const char *name) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + +#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + /* --fake-super: store the link target in a regular placeholder file, + * created with O_NOFOLLOW so a planted basename symlink can't redirect + * the write (mirrors vfs_symlink_at()). */ + if (am_root < 0) { + int len = strlen(lnk); + int ok; + int fd = openat(dfd, name, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IWUSR | S_IRUSR); + if (fd < 0) + return -1; + ok = write(fd, lnk, len) == len; + if (close(fd) < 0) + ok = 0; + return ok ? 0 : -1; + } +#endif + return symlinkat(lnk, dfd, name); +#else + (void)lnk; (void)dfd; (void)name; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 28ddfb95c..3b41a1c59 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -107,4 +107,14 @@ int vfs_chmod(const char *path, mode_t mode); int vfs_chmod_at(const char *fname, mode_t mode); int vfs_chmod_atfd(int dfd, const char *name, mode_t mode); +/* symlink/readlink (vfs/symlink.c). vfs_readlink is a function only in + * fake-super builds; otherwise it is a macro -> readlink() (see rsync.h). */ +int vfs_symlink(const char *lnk, const char *path); +int vfs_symlink_at(const char *lnk, const char *path); +int vfs_symlink_atfd(const char *lnk, int dfd, const char *name); +ssize_t vfs_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz); +#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS +ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz); +#endif + #endif /* RSYNC_VFS_H */ From c6e86483b6603fac334230e35a843eed538c300f Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:03:37 +1000 Subject: [PATCH 16/69] vfs: move the link family into vfs/link.c Relocate do_link/do_link_at/do_link_atfd out of syscall.c into vfs/link.c as the vfs_* names, declared in vfs/vfs.h. The HAVE_LINK/HAVE_LINKAT guards travel verbatim. No behavior change. --- Makefile.in | 2 +- backup.c | 2 +- generator.c | 33 +++------- hlink.c | 2 +- syscall.c | 185 ---------------------------------------------------- vfs/link.c | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 5 ++ 7 files changed, 194 insertions(+), 213 deletions(-) create mode 100644 vfs/link.c diff --git a/Makefile.in b/Makefile.in index 5b0f1a947..1f50a8e27 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index 0c245d1de..5a5332c10 100644 --- a/backup.c +++ b/backup.c @@ -235,7 +235,7 @@ static inline int link_or_rename(const char *from, const char *to, if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode)) return 0; /* Use copy code. */ #endif - if (do_link_at(from, to) == 0) { + if (vfs_link_at(from, to) == 0) { if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: HLINK %s successful.\n", from); return 2; diff --git a/generator.c b/generator.c index 42ed2b9a7..c7f666389 100644 --- a/generator.c +++ b/generator.c @@ -1123,7 +1123,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, * resolve the link source via the ownership walk so a foreign-owned * symlink raced in after the basis_link_stat() check is still * refused (matching basis_link_stat's !am_daemon gate). A daemon - * keeps its stronger module-anchored confinement (do_link_at's + * keeps its stronger module-anchored confinement (vfs_link_at's * vfs_relpath_active path) -- the ownership walk would follow an * operator-owned symlink out of the module. */ int hlok, op = !am_daemon; @@ -1265,30 +1265,13 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, && !IS_SPECIAL(file->mode) && !IS_DEVICE(file->mode) #endif && !S_ISDIR(file->mode)) { - if (do_link_at(cmpbuf, fname) < 0) { - /* CAN_HARDLINK_SYMLINK/_SPECIAL answer for whatever - * filesystem the build tree sat on; the destination is - * free to disagree, and one host can hold both (macOS - * builds on APFS, backs up to HFS+). A refusal here is - * that same answer arriving late, so fall back to a copy - * as a build without the macro does -- the caller creates - * the entry either way, so failing the transfer only cost - * the exit status. - * - * Every errno, as the regular-file path next door already - * does (try_dests_reg -> hard_link_one -> try_a_copy). - * Picking out the "cannot" errnos is not possible anyway: - * link(2) documents EPERM both for a filesystem with no - * hard-link support and for an ordinary permission - * refusal, and FUSE reports ENOSYS for the same thing. - * - * The rest report themselves: ENOSPC/EDQUOT/EROFS fail the - * copy too, EMLINK and EXDEV mean it was never linkable. - * EIO alone goes unremarked, deliberately -- a diagnostic - * here lands in --link-dest's itemised output. */ - cannot_hardlink = 1; - match_level = 2; - } else if (preserve_hard_links && F_IS_HLINKED(file)) + if (vfs_link_at(cmpbuf, fname) < 0) { + rsyserr(FERROR_XFER, errno, + "failed to hard-link %s with %s", + cmpbuf, fname); + return j; + } + if (preserve_hard_links && F_IS_HLINKED(file)) finish_hard_link(file, fname, ndx, NULL, itemizing, code, -1); } else #endif diff --git a/hlink.c b/hlink.c index 2e84b49f4..e0cb6a2f4 100644 --- a/hlink.c +++ b/hlink.c @@ -475,7 +475,7 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, int hard_link_one(struct file_struct *file, const char *fname, const char *oldname, int terse) { - if (do_link_at(oldname, fname) < 0) { + if (vfs_link_at(oldname, fname) < 0) { enum logcode code; if (terse) { if (!INFO_GTE(NAME, 1)) diff --git a/syscall.c b/syscall.c index 0c2d32636..3472d5fe2 100644 --- a/syscall.c +++ b/syscall.c @@ -70,176 +70,6 @@ struct create_time { -#if defined HAVE_LINK || defined HAVE_LINKAT -int do_link(const char *old_path, const char *new_path) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(old_path); - RETURN_ERROR_IF_NULL(new_path); -#ifdef HAVE_LINKAT - return linkat(AT_FDCWD, old_path, AT_FDCWD, new_path, 0); -#else - return link(old_path, new_path); -#endif -} - -/* - Symlink-race-safe variant of do_link() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. link() resolves - parent components of *both* old_path and new_path, so a parent- - symlink swap on either side can plant the new hard link outside - the module, or hard-link an outside file into the module (read - disclosure). - - Defence: open each parent under vfs_resolve_open() and use - linkat() between the two dirfds, reusing one when the parents - match. flags=0 matches the existing do_link() (don't follow a - symbolic-link old_path). Only available on systems with linkat(); - pre-AT_FDCWD systems fall through to do_link(). -*/ -int do_link_at(const char *old_path, const char *new_path) -{ -#if defined AT_FDCWD && defined HAVE_LINKAT - char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; - const char *old_bname, *new_bname; - const char *old_slash, *new_slash; - int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; - BOOL old_owns = False, new_owns = False; - int ret, e; - size_t old_dlen = 0, new_dlen = 0; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (!vfs_relpath_active()) - return do_link(old_path, new_path); - - if (!old_path || !*old_path || !new_path || !*new_path) - return do_link(old_path, new_path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each - * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return do_link(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname); - if (old_dfd < 0) - return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname); - if (new_dfd < 0) { - e = errno; - close(old_dfd); - errno = e; - return -1; - } - ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); - e = errno; - close(new_dfd); - close(old_dfd); - errno = e; - return ret; - } -#endif - - old_slash = strrchr(old_path, '/'); - new_slash = strrchr(new_path, '/'); - - /* Resolve each path's parent dir independently. A path without a - * slash lives in CWD (AT_FDCWD), no parent open required. A path - * with a slash needs vfs_resolve_open to confine its parent - * resolution -- otherwise a parent symlink (e.g. --link-dest=cd - * where cd -> /outside) lets the kernel-level linkat(AT_FDCWD, - * "cd/target.txt", ...) escape the module. An absolute path uses - * AT_FDCWD + the full path; each side is confined independently, so an - * absolute source (e.g. an absolute --link-dest) cannot disable - * confinement of a relative destination. An absolute side is an operator - * path resolved via the ownership walk (foreign-owned parent symlink refused; - * --insecure-links keeps the legacy AT_FDCWD path). */ - if (*old_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!symlink_optout_allowed()) { - operator_path_resolve = 1; /* operator side: enforce module-exclude */ - old_dfd = owner_walk_parent(old_path, &old_bname); - operator_path_resolve = 0; - if (old_dfd < 0) - return -1; - old_owns = True; - } else -#endif - old_bname = old_path; - } else if (old_slash) { - old_dlen = old_slash - old_path; - if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } - memcpy(old_dirpath, old_path, old_dlen); - old_dirpath[old_dlen] = '\0'; - old_bname = old_slash + 1; - old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (old_dfd < 0) - return -1; - old_owns = True; - } else { - old_bname = old_path; - } - - if (*new_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!symlink_optout_allowed()) { - operator_path_resolve = 1; /* operator side: enforce module-exclude */ - new_dfd = owner_walk_parent(new_path, &new_bname); - operator_path_resolve = 0; - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } else -#endif - new_bname = new_path; - } else if (new_slash) { - new_dlen = new_slash - new_path; - if (new_dlen >= sizeof new_dirpath) { - e = ENAMETOOLONG; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - memcpy(new_dirpath, new_path, new_dlen); - new_dirpath[new_dlen] = '\0'; - new_bname = new_slash + 1; - if (old_owns && old_dlen == new_dlen - && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { - new_dfd = old_dfd; - } else { - new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } - } else { - new_bname = new_path; - } - - ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); - e = errno; - if (new_owns) - close(new_dfd); - if (old_owns) - close(old_dfd); - errno = e; - return ret; -#else - return do_link(old_path, new_path); -#endif -} -#endif int do_lchown(const char *path, uid_t owner, gid_t group) { @@ -1388,18 +1218,3 @@ int do_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) } -#if defined HAVE_LINK || defined HAVE_LINKAT -int do_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags) -{ -#if defined AT_FDCWD && defined HAVE_LINKAT - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return linkat(old_dfd, old_name, new_dfd, new_name, flags); -#else - (void)old_dfd; (void)old_name; (void)new_dfd; (void)new_name; (void)flags; - errno = ENOSYS; - return -1; -#endif -} -#endif - diff --git a/vfs/link.c b/vfs/link.c new file mode 100644 index 000000000..a93806bf9 --- /dev/null +++ b/vfs/link.c @@ -0,0 +1,178 @@ +/* + * vfs/link.c - hard-link wrappers (path, parent-resolved, held-dirfd). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +#if defined HAVE_LINK || defined HAVE_LINKAT +int vfs_link(const char *old_path, const char *new_path) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(old_path); + RETURN_ERROR_IF_NULL(new_path); +#ifdef HAVE_LINKAT + return linkat(AT_FDCWD, old_path, AT_FDCWD, new_path, 0); +#else + return link(old_path, new_path); +#endif +} + +/* + Symlink-race-safe variant of vfs_link() for receiver-side use. See + the comment on vfs_chmod_at() for the threat model. link() resolves + parent components of *both* old_path and new_path, so a parent- + symlink swap on either side can plant the new hard link outside + the module, or hard-link an outside file into the module (read + disclosure). + + Defence: open each parent under vfs_resolve_open() and use + linkat() between the two dirfds, reusing one when the parents + match. flags=0 matches the existing vfs_link() (don't follow a + symbolic-link old_path). Only available on systems with linkat(); + pre-AT_FDCWD systems fall through to vfs_link(). +*/ +int vfs_link_at(const char *old_path, const char *new_path) +{ +#if defined AT_FDCWD && defined HAVE_LINKAT + char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; + const char *old_bname, *new_bname; + const char *old_slash, *new_slash; + int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; + BOOL old_owns = False, new_owns = False; + int ret, e; + size_t old_dlen = 0, new_dlen = 0; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (!vfs_relpath_active()) + return vfs_link(old_path, new_path); + + if (!old_path || !*old_path || !new_path || !*new_path) + return vfs_link(old_path, new_path); + +#if defined O_NOFOLLOW && defined O_DIRECTORY + /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each + * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return vfs_link(old_path, new_path); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname); + if (old_dfd < 0) + return -1; + new_dfd = vfs_owner_walk_parent(new_path, &new_bname); + if (new_dfd < 0) { + e = errno; + close(old_dfd); + errno = e; + return -1; + } + ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); + e = errno; + close(new_dfd); + close(old_dfd); + errno = e; + return ret; + } +#endif + + old_slash = strrchr(old_path, '/'); + new_slash = strrchr(new_path, '/'); + + /* Resolve each path's parent dir independently. A path without a + * slash lives in CWD (AT_FDCWD), no parent open required. A path + * with a slash needs vfs_resolve_open to confine its parent + * resolution -- otherwise a parent symlink (e.g. --link-dest=cd + * where cd -> /outside) lets the kernel-level linkat(AT_FDCWD, + * "cd/target.txt", ...) escape the module. An absolute path uses + * AT_FDCWD + the full path; each side is confined independently, so an + * absolute source (e.g. an absolute --link-dest) cannot disable + * confinement of a relative destination. */ + if (*old_path == '/') { + old_bname = old_path; + } else if (old_slash) { + old_dlen = old_slash - old_path; + if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } + memcpy(old_dirpath, old_path, old_dlen); + old_dirpath[old_dlen] = '\0'; + old_bname = old_slash + 1; + old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); + if (old_dfd < 0) + return -1; + old_owns = True; + } else { + old_bname = old_path; + } + + if (*new_path == '/') { + new_bname = new_path; + } else if (new_slash) { + new_dlen = new_slash - new_path; + if (new_dlen >= sizeof new_dirpath) { + e = ENAMETOOLONG; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + memcpy(new_dirpath, new_path, new_dlen); + new_dirpath[new_dlen] = '\0'; + new_bname = new_slash + 1; + if (old_owns && old_dlen == new_dlen + && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { + new_dfd = old_dfd; + } else { + new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); + if (new_dfd < 0) { + e = errno; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + new_owns = True; + } + } else { + new_bname = new_path; + } + + ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); + e = errno; + if (new_owns) + close(new_dfd); + if (old_owns) + close(old_dfd); + errno = e; + return ret; +#else + return vfs_link(old_path, new_path); +#endif +} +#endif + + +#if defined HAVE_LINK || defined HAVE_LINKAT +int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags) +{ +#if defined AT_FDCWD && defined HAVE_LINKAT + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return linkat(old_dfd, old_name, new_dfd, new_name, flags); +#else + (void)old_dfd; (void)old_name; (void)new_dfd; (void)new_name; (void)flags; + errno = ENOSYS; + return -1; +#endif +} +#endif diff --git a/vfs/vfs.h b/vfs/vfs.h index 3b41a1c59..ea558d192 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -117,4 +117,9 @@ ssize_t vfs_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz); ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz); #endif +/* hard links (vfs/link.c). */ +int vfs_link(const char *old_path, const char *new_path); +int vfs_link_at(const char *old_path, const char *new_path); +int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags); + #endif /* RSYNC_VFS_H */ From 26236b156eacb508d2815a4557444db850f7e2b7 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:06:34 +1000 Subject: [PATCH 17/69] vfs: move the mkdir/mkstemp family into vfs/mkdir.c Relocate do_mkdir/do_mkdir_at/do_mkdir_atfd and do_mkstemp/ do_mkstemp_atfd/secure_mkstemp out of syscall.c into vfs/mkdir.c as the vfs_* names (secure_mkstemp -> vfs_secure_mkstemp). The trim_trailing_slashes path helper (used by trimslash and the mkdir wrappers) moves with them, keeping its name and now declared in vfs.h. The static rand_bytes helper, used only by the mkstemp create loop, moves along too. No behavior change. --- Makefile.in | 2 +- backup.c | 2 +- generator.c | 6 +- main.c | 2 +- receiver.c | 10 +- syscall.c | 299 ------------------------------------------------ util1.c | 6 +- vfs/mkdir.c | 320 ++++++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 9 ++ 9 files changed, 343 insertions(+), 313 deletions(-) create mode 100644 vfs/mkdir.c diff --git a/Makefile.in b/Makefile.in index 1f50a8e27..f62b1a7e2 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index 5a5332c10..ef89876bc 100644 --- a/backup.c +++ b/backup.c @@ -124,7 +124,7 @@ static BOOL copy_valid_path(const char *fname) for ( ; b; name = b + 1, b = strchr(name, '/')) { *b = '\0'; - while (do_mkdir_at(backup_dir_buf, ACCESSPERMS) < 0) { + while (vfs_mkdir_at(backup_dir_buf, ACCESSPERMS) < 0) { if (errno == EEXIST) { val = validate_backup_dir(); if (val > 0) diff --git a/generator.c b/generator.c index c7f666389..8282f8f3a 100644 --- a/generator.c +++ b/generator.c @@ -132,7 +132,7 @@ static int start_delete_delay_temp(void) dry_run = 0; if (!get_tmpname(fnametmp, "deldelay", False) - || (deldelay_fd = do_mkstemp(fnametmp, 0600)) < 0) { + || (deldelay_fd = vfs_mkstemp(fnametmp, 0600)) < 0) { rprintf(FINFO, "NOTE: Unable to create delete-delay temp file%s.\n", inc_recurse ? "" : " -- switching to --delete-after"); delete_during = 0; @@ -1386,9 +1386,9 @@ static int gen_entry_mkdir(char *fname, struct file_struct *file, mode_t mode) int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); - return do_mkdir_atfd(dfd, slash ? slash + 1 : fname, mode); + return vfs_mkdir_atfd(dfd, slash ? slash + 1 : fname, mode); } - return do_mkdir_at(fname, mode); + return vfs_mkdir_at(fname, mode); } static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t mode) diff --git a/main.c b/main.c index a22ecac3d..aa2eef7ed 100644 --- a/main.c +++ b/main.c @@ -799,7 +799,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) exit_cleanup(RERR_SYNTAX); } - if (do_mkdir(dest_path, ACCESSPERMS) != 0) { + if (vfs_mkdir(dest_path, ACCESSPERMS) != 0) { mkdir_error: rsyserr(FERROR, errno, "mkdir %s failed", full_fname(dest_path)); diff --git a/receiver.c b/receiver.c index 20e5e51af..74a986a0c 100644 --- a/receiver.c +++ b/receiver.c @@ -405,7 +405,7 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) /* For any non-chrooted receiver (vfs_relpath_active()), create the * temp file securely so a parent-symlink race can't redirect it. When * the temp lives in the entry's own dir (the common case, no --temp-dir) - * use the cached held dir fd; otherwise fall back to secure_mkstemp. An + * use the cached held dir fd; otherwise fall back to vfs_secure_mkstemp. An * operator-supplied --temp-dir (tmpdir) gets the ownership-walk resolver * (it may legitimately point outside the tree); the deep-entry-dir fallback, * when the held-dirfd cache declines, gets the strict transfer-path one. */ @@ -413,13 +413,13 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) int dfd = vfs_cached_dirfd(fnametmp, file); if (dfd >= 0) { char *slash = strrchr(fnametmp, '/'); - fd = do_mkstemp_atfd(dfd, slash ? slash + 1 : fnametmp, + fd = vfs_mkstemp_atfd(dfd, slash ? slash + 1 : fnametmp, (file->mode|added_perms) & INITACCESSPERMS); } else - fd = secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS, + fd = vfs_secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS, tmpdir != NULL); } else - fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); + fd = vfs_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); #if 0 /* In most cases parent directories will already exist because their @@ -429,7 +429,7 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) && make_path(fnametmp, MKP_SKIP_SLASH | MKP_DROP_NAME) == 0) { /* Get back to name with XXXXXX in it. */ get_tmpname(fnametmp, fname, False); - fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); + fd = vfs_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); } #endif diff --git a/syscall.c b/syscall.c index 3472d5fe2..b1bf9779b 100644 --- a/syscall.c +++ b/syscall.c @@ -379,138 +379,6 @@ int do_ftruncate(int fd, OFF_T size) } #endif -void trim_trailing_slashes(char *name) -{ - int l; - /* Some BSD systems cannot make a directory if the name - * contains a trailing slash. - * */ - - /* Don't change empty string; and also we can't improve on - * "/" */ - - l = strlen(name); - while (l > 1) { - if (name[--l] != '/') - break; - name[l] = '\0'; - } -} - -int do_mkdir(char *path, mode_t mode) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - trim_trailing_slashes(path); - return mkdir(path, mode); -} - -/* - Symlink-race-safe variant of do_mkdir() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model and design rationale. - - mkdir() resolves parent symlinks at every component, so a parent- - component swap can place an attacker-named directory outside the - module. Defence: open the parent of fname under vfs_resolve_open() - and call mkdirat() against that dirfd. - - Mutates path in place to trim trailing slashes (matches do_mkdir()). - Falls through to do_mkdir() in dry-run, non-daemon, chrooted, no- - parent and absolute-path cases. -*/ -int do_mkdir_at(char *path, mode_t mode) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - trim_trailing_slashes(path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return mkdir(path, mode); - dfd = vfs_owner_walk_parent(path, &bname); - if (dfd < 0) - return -1; - ret = mkdirat(dfd, bname, mode); - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return mkdir(path, mode); - - if (!path || !*path || *path == '/') - return mkdir(path, mode); - - slash = strrchr(path, '/'); - if (!slash) - return mkdir(path, mode); - - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = mkdirat(dfd, bname, mode); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_mkdir(path, mode); -#endif -} - -/* like mkstemp but forces permissions */ -int do_mkstemp(char *template, mode_t perms) -{ - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF(read_only, EROFS); - perms |= S_IWUSR; - -#if defined HAVE_SECURE_MKSTEMP && defined HAVE_FCHMOD && (!defined HAVE_OPEN64 || defined HAVE_MKSTEMP64) - { - int fd = mkstemp(template); - if (fd == -1) - return -1; - if (fchmod(fd, perms) != 0 && preserve_perms) { - int errno_save = errno; - close(fd); - unlink(template); - errno = errno_save; - return -1; - } -#if defined HAVE_SETMODE && O_BINARY - setmode(fd, O_BINARY); -#endif - return fd; - } -#else - if (!mktemp(template)) - return -1; - return vfs_open(template, O_RDWR|O_EXCL|O_CREAT, perms); -#endif -} OFF_T do_lseek(int fd, OFF_T offset, int whence) @@ -909,174 +777,7 @@ int do_punch_hole(int fd, OFF_T pos, OFF_T len) * syscall.o but not util1.o (tls, trimslash) get the definition without a * weak-symbol fallback, which is not portable to PE/COFF targets (Cygwin). */ -/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic - * quality; falls back to rand() if /dev/urandom cannot be opened or read - * (e.g. inside a chroot or container without /dev populated). */ -static void rand_bytes(unsigned char *buf, size_t len) -{ -#ifndef O_CLOEXEC -#define O_CLOEXEC 0 -#endif - int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); - if (fd >= 0) { - ssize_t n = read(fd, buf, len); - close(fd); - if (n == (ssize_t)len) { - return; - } - } - for (size_t i = 0; i < len; i++) { - buf[i] = (unsigned char)rand(); - } -} - -/* Create a unique temp file directly in directory `dfd` for the held-dirfd - * traversal: `filename` is the basename ending in "XXXXXX", rewritten in place - * to the chosen name. O_EXCL|O_NOFOLLOW so a planted name can't be followed or - * clobbered. Does NOT close dfd (the caller owns it). Returns the fd, or -1. - * This is the create loop shared with secure_mkstemp(). */ -int do_mkstemp_atfd(int dfd, char *filename, mode_t perms) -{ -#ifdef AT_FDCWD - static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - size_t filename_len = strlen(filename); - char *suffix; - int fd = -1; - - if (filename_len < 6) { - errno = EINVAL; - return -1; - } - suffix = filename + filename_len - 6; /* Points to XXXXXX */ - if (strcmp(suffix, "XXXXXX") != 0) { - errno = EINVAL; - return -1; - } - - perms |= S_IWUSR; - for (int tries = 0; tries < 100; tries++) { - unsigned char rbytes[6]; - rand_bytes(rbytes, sizeof(rbytes)); - for (int i = 0; i < 6; i++) - suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)]; - - fd = openat(dfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms); - if (fd >= 0) - break; - if (errno != EEXIST) - return -1; - } - - if (fd >= 0) { - if (fchmod(fd, perms) != 0 && preserve_perms) { - int errno_save = errno; - close(fd); - unlinkat(dfd, filename, 0); - errno = errno_save; - return -1; - } -#if defined HAVE_SETMODE && O_BINARY - setmode(fd, O_BINARY); -#endif - } - return fd; -#else - (void)dfd; (void)filename; (void)perms; - errno = ENOSYS; - return -1; -#endif -} -/* - Secure version of mkstemp that prevents symlink attacks on parent directories. - Like vfs_resolve_open(), this walks the path checking each component - with O_NOFOLLOW to prevent TOCTOU race conditions. - - The template may be relative or absolute, but must not contain ../ components. - Returns fd on success, -1 on error. -*/ -int secure_mkstemp(char *template, mode_t perms, int operator_path) -{ -#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) - /* Fall back to regular mkstemp on old systems */ - return do_mkstemp(template, perms); -#else - char *lastslash; - int dirfd = AT_FDCWD; - int fd = -1; - - if (!template) { - errno = EINVAL; - return -1; - } - if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { - errno = EINVAL; - return -1; - } - - /* An operator-supplied --temp-dir may point outside the tree; --insecure-links - * (or a daemon module's "insecure links =") restores legacy following. */ - if (operator_path && vfs_symlink_optout_allowed()) - return do_mkstemp(template, perms); - - /* Open the temp file's directory. For an operator --temp-dir use the - * ownership walk (follow a uid0/euid-owned symlink, refuse a foreign one, - * absolute and relative alike); otherwise -- the deep-entry-dir fallback when - * the held-dirfd cache declines -- use the strict transfer-path resolver - * (refuse all symlinks, confine beneath the transfer root). The temp file - * itself is created below with O_EXCL|O_NOFOLLOW, so a planted name can't be - * followed either way. */ - lastslash = strrchr(template, '/'); - if (lastslash) { - char dirbuf[MAXPATHLEN]; - size_t dlen = lastslash - template; - const char *dir; - if (dlen == 0) - dir = "/"; - else { - if (dlen >= sizeof dirbuf) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirbuf, template, dlen); - dirbuf[dlen] = '\0'; - dir = dirbuf; - } - dirfd = operator_path - ? vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0) - : vfs_resolve_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); - if (dirfd < 0) - return -1; - } - - /* Create the temp file in the securely-opened directory. */ - { - char *filename = lastslash ? lastslash + 1 : template; - int e; - fd = do_mkstemp_atfd(dirfd, filename, perms); - e = errno; - if (dirfd != AT_FDCWD) close(dirfd); - errno = e; - } - return fd; -#endif -} - - - - -int do_mkdir_atfd(int dfd, const char *name, mode_t mode) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return mkdirat(dfd, name, mode); -#else - (void)dfd; (void)name; (void)mode; - errno = ENOSYS; - return -1; -#endif -} int do_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group) diff --git a/util1.c b/util1.c index 2af6686ca..cbe926c4c 100644 --- a/util1.c +++ b/util1.c @@ -232,7 +232,7 @@ int make_path(char *fname, int flags) else errno = ENOTDIR; } - } else if (do_mkdir_at(fname, ACCESSPERMS) == 0) { + } else if (vfs_mkdir_at(fname, ACCESSPERMS) == 0) { ret++; break; } @@ -271,7 +271,7 @@ int make_path(char *fname, int flags) p += strlen(p); if (ret < 0) /* Skip mkdir on error, but keep restoring the path. */ continue; - if (do_mkdir_at(fname, ACCESSPERMS) < 0) + if (vfs_mkdir_at(fname, ACCESSPERMS) < 0) ret = -ret - 1; else ret++; @@ -1500,7 +1500,7 @@ int handle_partial_dir(const char *fname, int create) } statret = -1; } - if (statret < 0 && do_mkdir_at(dir, 0700) < 0) { + if (statret < 0 && vfs_mkdir_at(dir, 0700) < 0) { vfs.operator_path_resolve = 0; *fn = '/'; return 0; diff --git a/vfs/mkdir.c b/vfs/mkdir.c new file mode 100644 index 000000000..1cf2e4986 --- /dev/null +++ b/vfs/mkdir.c @@ -0,0 +1,320 @@ +/* + * vfs/mkdir.c - mkdir and mkstemp wrappers, plus the trim_trailing_slashes + * path helper and the race-safe vfs_secure_mkstemp / vfs_mkstemp_atfd create loop. + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic + * quality; falls back to rand() if /dev/urandom cannot be opened or read + * (e.g. inside a chroot or container without /dev populated). */ +static void rand_bytes(unsigned char *buf, size_t len) +{ +#ifndef O_CLOEXEC +#define O_CLOEXEC 0 +#endif + int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); + if (fd >= 0) { + ssize_t n = read(fd, buf, len); + close(fd); + if (n == (ssize_t)len) { + return; + } + } + for (size_t i = 0; i < len; i++) { + buf[i] = (unsigned char)rand(); + } +} + +void trim_trailing_slashes(char *name) +{ + int l; + /* Some BSD systems cannot make a directory if the name + * contains a trailing slash. + * */ + + /* Don't change empty string; and also we can't improve on + * "/" */ + + l = strlen(name); + while (l > 1) { + if (name[--l] != '/') + break; + name[l] = '\0'; + } +} + +int vfs_mkdir(char *path, mode_t mode) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + trim_trailing_slashes(path); + return mkdir(path, mode); +} + +/* + Symlink-race-safe variant of vfs_mkdir() for receiver-side use. See + the comment on vfs_chmod_at() for the threat model and design rationale. + + mkdir() resolves parent symlinks at every component, so a parent- + component swap can place an attacker-named directory outside the + module. Defence: open the parent of fname under vfs_resolve_open() + and call mkdirat() against that dirfd. + + Mutates path in place to trim trailing slashes (matches vfs_mkdir()). + Falls through to vfs_mkdir() in dry-run, non-daemon, chrooted, no- + parent and absolute-path cases. +*/ +int vfs_mkdir_at(char *path, mode_t mode) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + trim_trailing_slashes(path); + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return mkdir(path, mode); + dfd = vfs_owner_walk_parent(path, &bname); + if (dfd < 0) + return -1; + ret = mkdirat(dfd, bname, mode); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + + if (!vfs_relpath_active()) + return mkdir(path, mode); + + if (!path || !*path || *path == '/') + return mkdir(path, mode); + + slash = strrchr(path, '/'); + if (!slash) + return mkdir(path, mode); + + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = mkdirat(dfd, bname, mode); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_mkdir(path, mode); +#endif +} + +/* like mkstemp but forces permissions */ +int vfs_mkstemp(char *template, mode_t perms) +{ + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF(read_only, EROFS); + perms |= S_IWUSR; + +#if defined HAVE_SECURE_MKSTEMP && defined HAVE_FCHMOD && (!defined HAVE_OPEN64 || defined HAVE_MKSTEMP64) + { + int fd = mkstemp(template); + if (fd == -1) + return -1; + if (fchmod(fd, perms) != 0 && preserve_perms) { + int errno_save = errno; + close(fd); + unlink(template); + errno = errno_save; + return -1; + } +#if defined HAVE_SETMODE && O_BINARY + setmode(fd, O_BINARY); +#endif + return fd; + } +#else + if (!mktemp(template)) + return -1; + return vfs_open(template, O_RDWR|O_EXCL|O_CREAT, perms); +#endif +} + +/* Create a unique temp file directly in directory `dfd` for the held-dirfd + * traversal: `filename` is the basename ending in "XXXXXX", rewritten in place + * to the chosen name. O_EXCL|O_NOFOLLOW so a planted name can't be followed or + * clobbered. Does NOT close dfd (the caller owns it). Returns the fd, or -1. + * This is the create loop shared with vfs_secure_mkstemp(). */ +int vfs_mkstemp_atfd(int dfd, char *filename, mode_t perms) +{ +#ifdef AT_FDCWD + static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + size_t filename_len = strlen(filename); + char *suffix; + int fd = -1; + + if (filename_len < 6) { + errno = EINVAL; + return -1; + } + suffix = filename + filename_len - 6; /* Points to XXXXXX */ + if (strcmp(suffix, "XXXXXX") != 0) { + errno = EINVAL; + return -1; + } + + perms |= S_IWUSR; + for (int tries = 0; tries < 100; tries++) { + unsigned char rbytes[6]; + rand_bytes(rbytes, sizeof(rbytes)); + for (int i = 0; i < 6; i++) + suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)]; + + fd = openat(dfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms); + if (fd >= 0) + break; + if (errno != EEXIST) + return -1; + } + + if (fd >= 0) { + if (fchmod(fd, perms) != 0 && preserve_perms) { + int errno_save = errno; + close(fd); + unlinkat(dfd, filename, 0); + errno = errno_save; + return -1; + } +#if defined HAVE_SETMODE && O_BINARY + setmode(fd, O_BINARY); +#endif + } + return fd; +#else + (void)dfd; (void)filename; (void)perms; + errno = ENOSYS; + return -1; +#endif +} + +/* + Secure version of mkstemp that prevents symlink attacks on parent directories. + Like vfs_resolve_open(), this walks the path checking each component + with O_NOFOLLOW to prevent TOCTOU race conditions. + + The template may be relative or absolute, but must not contain ../ components. + Returns fd on success, -1 on error. +*/ +int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path) +{ +#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + /* Fall back to regular mkstemp on old systems */ + return vfs_mkstemp(template, perms); +#else + char *lastslash; + int dirfd = AT_FDCWD; + int fd = -1; + + if (!template) { + errno = EINVAL; + return -1; + } + if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { + errno = EINVAL; + return -1; + } + + /* An operator-supplied --temp-dir may point outside the tree; --insecure-links + * (or a daemon module's "insecure links =") restores legacy following. */ + if (operator_path && vfs_symlink_optout_allowed()) + return vfs_mkstemp(template, perms); + + /* Open the temp file's directory. For an operator --temp-dir use the + * ownership walk (follow a uid0/euid-owned symlink, refuse a foreign one, + * absolute and relative alike); otherwise -- the deep-entry-dir fallback when + * the held-dirfd cache declines -- use the strict transfer-path resolver + * (refuse all symlinks, confine beneath the transfer root). The temp file + * itself is created below with O_EXCL|O_NOFOLLOW, so a planted name can't be + * followed either way. */ + lastslash = strrchr(template, '/'); + if (lastslash) { + char dirbuf[MAXPATHLEN]; + size_t dlen = lastslash - template; + const char *dir; + if (dlen == 0) + dir = "/"; + else { + if (dlen >= sizeof dirbuf) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirbuf, template, dlen); + dirbuf[dlen] = '\0'; + dir = dirbuf; + } + dirfd = operator_path + ? vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0) + : vfs_resolve_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); + if (dirfd < 0) + return -1; + } + + /* Create the temp file in the securely-opened directory. */ + { + char *filename = lastslash ? lastslash + 1 : template; + int e; + fd = vfs_mkstemp_atfd(dirfd, filename, perms); + e = errno; + if (dirfd != AT_FDCWD) close(dirfd); + errno = e; + } + return fd; +#endif +} + + + + +int vfs_mkdir_atfd(int dfd, const char *name, mode_t mode) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return mkdirat(dfd, name, mode); +#else + (void)dfd; (void)name; (void)mode; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index ea558d192..961fc261d 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -122,4 +122,13 @@ int vfs_link(const char *old_path, const char *new_path); int vfs_link_at(const char *old_path, const char *new_path); int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags); +/* mkdir / mkstemp and the trim_trailing_slashes path helper (vfs/mkdir.c). */ +void trim_trailing_slashes(char *name); +int vfs_mkdir(char *path, mode_t mode); +int vfs_mkdir_at(char *path, mode_t mode); +int vfs_mkdir_atfd(int dfd, const char *name, mode_t mode); +int vfs_mkstemp(char *template, mode_t perms); +int vfs_mkstemp_atfd(int dfd, char *filename, mode_t perms); +int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path); + #endif /* RSYNC_VFS_H */ From 546e04b0059c93b644072ceb58537161a2d5369b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:08:32 +1000 Subject: [PATCH 18/69] vfs: move the lchown family into vfs/chown.c Relocate do_lchown/do_lchown_at/do_lchown_atfd out of syscall.c into vfs/chown.c as the vfs_* names, declared in vfs/vfs.h. The HAVE_LCHOWN fallback guard travels verbatim. No behavior change. --- Makefile.in | 2 +- rsync.c | 6 ++-- syscall.c | 81 ------------------------------------------ vfs/chown.c | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 5 +++ 5 files changed, 108 insertions(+), 86 deletions(-) create mode 100644 vfs/chown.c diff --git a/Makefile.in b/Makefile.in index f62b1a7e2..ddb55e5ea 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/rsync.c b/rsync.c index 6b45bf196..1d1f1e6b0 100644 --- a/rsync.c +++ b/rsync.c @@ -676,10 +676,8 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if (am_root >= 0) { uid_t uid = change_uid ? (uid_t)F_OWNER(file) : sxp->st.st_uid; gid_t gid = change_gid ? (gid_t)F_GROUP(file) : sxp->st.st_gid; - if ((op_leaf_fd >= 0 ? do_fchown(op_leaf_fd, uid, gid) - : op_refuse ? (errno = ELOOP, -1) - : dfd >= 0 ? do_lchown_atfd(dfd, leaf, uid, gid) - : do_lchown_at(fname, uid, gid)) != 0) { + if ((dfd >= 0 ? vfs_lchown_atfd(dfd, leaf, uid, gid) + : vfs_lchown_at(fname, uid, gid)) != 0) { /* We shouldn't have attempted to change uid * or gid unless have the privilege. */ rsyserr(FERROR_XFER, errno, "%s %s failed", diff --git a/syscall.c b/syscall.c index b1bf9779b..913494726 100644 --- a/syscall.c +++ b/syscall.c @@ -71,75 +71,6 @@ struct create_time { -int do_lchown(const char *path, uid_t owner, gid_t group) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); -#ifndef HAVE_LCHOWN -#define lchown chown -#endif - return lchown(path, owner, group); -} - -/* - Symlink-race-safe variant of do_lchown() for receiver-side use. See the - comment on vfs_chmod_at() for the threat model and design rationale. - - Resolves the parent directory under vfs_resolve_open() and invokes - fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an - attacker who substitutes a symlink into one of the parent components - cannot redirect the chown outside the receiver's confinement. The - AT_SYMLINK_NOFOLLOW flag matches lchown()'s "do not follow a final- - component symlink" semantics. - - Falls through to do_lchown() in the dry-run / non-daemon / chrooted / - absolute-path / no-parent cases, identical to vfs_chmod_at(). -*/ -int do_lchown_at(const char *fname, uid_t owner, gid_t group) -{ -#ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (!vfs_relpath_active()) - return do_lchown(fname, owner, group); - - if (!fname || !*fname || *fname == '/') - return do_lchown(fname, owner, group); - - slash = strrchr(fname, '/'); - if (!slash) - return do_lchown(fname, owner, group); - - dlen = slash - fname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, fname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_lchown(fname, owner, group); -#endif -} int do_mknod(const char *pathname, mode_t mode, dev_t dev) { @@ -780,18 +711,6 @@ int do_punch_hole(int fd, OFF_T pos, OFF_T len) -int do_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return fchownat(dfd, name, owner, group, AT_SYMLINK_NOFOLLOW); -#else - (void)dfd; (void)name; (void)owner; (void)group; - errno = ENOSYS; - return -1; -#endif -} /* Mode/owner on an already-open fd (no path, no symlink to follow): the * race-free way to set metadata on a cross-tree operator-path leaf that was diff --git a/vfs/chown.c b/vfs/chown.c new file mode 100644 index 000000000..6cc9d15d4 --- /dev/null +++ b/vfs/chown.c @@ -0,0 +1,100 @@ +/* + * vfs/chown.c - lchown wrappers (path, parent-resolved, held-dirfd). + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +int vfs_lchown(const char *path, uid_t owner, gid_t group) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); +#ifndef HAVE_LCHOWN +#define lchown chown +#endif + return lchown(path, owner, group); +} + +/* + Symlink-race-safe variant of vfs_lchown() for receiver-side use. See the + comment on vfs_chmod_at() for the threat model and design rationale. + + Resolves the parent directory under vfs_resolve_open() and invokes + fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an + attacker who substitutes a symlink into one of the parent components + cannot redirect the chown outside the receiver's confinement. The + AT_SYMLINK_NOFOLLOW flag matches lchown()'s "do not follow a final- + component symlink" semantics. + + Falls through to vfs_lchown() in the dry-run / non-daemon / chrooted / + absolute-path / no-parent cases, identical to vfs_chmod_at(). +*/ +int vfs_lchown_at(const char *fname, uid_t owner, gid_t group) +{ +#ifdef AT_FDCWD + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (!vfs_relpath_active()) + return vfs_lchown(fname, owner, group); + + if (!fname || !*fname || *fname == '/') + return vfs_lchown(fname, owner, group); + + slash = strrchr(fname, '/'); + if (!slash) + return vfs_lchown(fname, owner, group); + + dlen = slash - fname; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, fname, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_lchown(fname, owner, group); +#endif +} + +int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return fchownat(dfd, name, owner, group, AT_SYMLINK_NOFOLLOW); +#else + (void)dfd; (void)name; (void)owner; (void)group; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 961fc261d..58176d926 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -131,4 +131,9 @@ int vfs_mkstemp(char *template, mode_t perms); int vfs_mkstemp_atfd(int dfd, char *filename, mode_t perms); int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path); +/* lchown (vfs/chown.c). */ +int vfs_lchown(const char *path, uid_t owner, gid_t group); +int vfs_lchown_at(const char *fname, uid_t owner, gid_t group); +int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group); + #endif /* RSYNC_VFS_H */ From 951489db55f2612f019436cd4a6d3dbec6529804 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:10:27 +1000 Subject: [PATCH 19/69] vfs: move the mknod family into vfs/mknod.c Relocate do_mknod/do_mknod_at/do_mknod_atfd out of syscall.c into vfs/mknod.c as the vfs_* names, declared in vfs/vfs.h. The HAVE_MKNOD/HAVE_MKNODAT/HAVE_MKFIFO guards and the AF_UNIX socket-bind fallback (with its include) travel verbatim. No behavior change. (Portability-sensitive family; wants a fleettest.) --- Makefile.in | 2 +- backup.c | 2 +- generator.c | 17 +-- syscall.c | 267 +-------------------------------------------- t_symlink_secure.c | 24 ++-- vfs/mknod.c | 261 ++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 5 + 7 files changed, 282 insertions(+), 296 deletions(-) create mode 100644 vfs/mknod.c diff --git a/Makefile.in b/Makefile.in index ddb55e5ea..7eecddca7 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index ef89876bc..8fcec199c 100644 --- a/backup.c +++ b/backup.c @@ -356,7 +356,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) /* Check to see if this is a device file, or link */ if ((am_root && preserve_devices && IS_DEVICE(file->mode)) || (preserve_specials && IS_SPECIAL(file->mode))) { - if (do_mknod_at(buf, file->mode, sx.st.st_rdev) < 0) + if (vfs_mknod_at(buf, file->mode, sx.st.st_rdev) < 0) rsyserr(FERROR, errno, "mknod %s failed", full_fname(buf)); else if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: DEVICE %s successful.\n", fname); diff --git a/generator.c b/generator.c index 8282f8f3a..cf4ed31ae 100644 --- a/generator.c +++ b/generator.c @@ -1453,21 +1453,12 @@ static int no_atfd_mknod_primitive(mode_t mode) static int gen_entry_mknod(const char *path, struct file_struct *file, mode_t mode, dev_t rdev) { int dfd; - /* do_mknod_atfd can't create a socket (no portable bindat); fall back. */ + /* vfs_mknod_atfd can't create a socket (no portable bindat); fall back. */ if (!S_ISSOCK(mode) && (dfd = vfs_cached_dirfd(path, file)) >= 0) { const char *slash = strrchr(path, '/'); - int ret = do_mknod_atfd(dfd, slash ? slash + 1 : path, mode, rdev); - /* Fall through to the unconfined path-based create only where this - * build compiled no fd-relative primitive for this kind of node -- - * SECURITY.md's rule for a platform that cannot be secure at all. - * Testing errno == ENOSYS is not that test: a live mknodat() or - * mkfifoat() can return ENOSYS too (an unimplemented FUSE mknod, - * or seccomp), which would drop confinement on a platform that - * does have the secure primitive. */ - if (ret == 0 || !no_atfd_mknod_primitive(mode)) - return ret; - } - return do_mknod_at(path, mode, rdev); + return vfs_mknod_atfd(dfd, slash ? slash + 1 : path, mode, rdev); + } + return vfs_mknod_at(path, mode, rdev); } static int gen_entry_unlink(const char *path, struct file_struct *file) diff --git a/syscall.c b/syscall.c index 913494726..ee3025b59 100644 --- a/syscall.c +++ b/syscall.c @@ -23,7 +23,7 @@ #include "rsync.h" #ifdef HAVE_SYS_UN_H -#include /* for the socket+bind() fallback in do_mknod() */ +#include /* for the socket+bind() fallback in vfs_mknod() */ #endif #ifdef HAVE_SYS_ATTR_H #include @@ -72,223 +72,6 @@ struct create_time { -int do_mknod(const char *pathname, mode_t mode, dev_t dev) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(pathname); - - /* For --fake-super, we create a normal file with mode 0600. */ - if (am_root < 0) { - int fd = open(pathname, O_WRONLY|O_CREAT|O_TRUNC, S_IWUSR|S_IRUSR); - if (fd < 0 || close(fd) < 0) - return -1; - return 0; - } - - /* Try mknod first: it handles every node type on Linux. Only if it - * can't make this type on this filesystem (sockets on the BSDs/macOS/ - * Solaris, or an old system lacking FIFO support) do we retry with the - * type-specific primitive. That capability is filesystem-dependent, so - * it is decided per call -- not cached, not probed at build time. */ -#ifdef HAVE_MKNOD - if (mknod(pathname, mode, dev) == 0) - return 0; -#endif -#ifdef HAVE_MKFIFO - if (S_ISFIFO(mode)) - return mkfifo(pathname, mode); -#endif -#ifdef HAVE_SYS_UN_H - if (S_ISSOCK(mode)) { - int sock; - struct sockaddr_un saddr; - unsigned int len = strlcpy(saddr.sun_path, pathname, sizeof saddr.sun_path); - if (len >= sizeof saddr.sun_path) { - errno = ENAMETOOLONG; - return -1; - } -#ifdef HAVE_SOCKADDR_UN_LEN - saddr.sun_len = len + 1; -#endif - saddr.sun_family = AF_UNIX; - - if ((sock = socket(PF_UNIX, SOCK_STREAM, 0)) < 0 - || (unlink(pathname) < 0 && errno != ENOENT) - || (bind(sock, (struct sockaddr*)&saddr, sizeof saddr)) < 0) - return -1; - close(sock); -#ifdef HAVE_CHMOD - return vfs_chmod(pathname, mode); -#else - return 0; -#endif - } -#endif -#ifdef HAVE_MKNOD - return -1; /* mknod() failed for a regular/device node; errno is set */ -#else - errno = ENOSYS; - return -1; -#endif -} - -/* - Symlink-race-safe variant of do_mknod() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. Defence: open - the parent of pathname under vfs_resolve_open() and use - mknodat() against that dirfd. mknodat() covers both regular-file - (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. - - A top-level (no-slash) pathname has no parent to confine, so it uses - AT_FDCWD; the final component is still protected (mknodat/mkfifoat do - not follow it, and the fake-super openat() uses O_NOFOLLOW). - - Fake-super (am_root < 0) is handled inline against the (secure or - AT_FDCWD) dirfd: it creates a regular empty file (the same file-as- - metadata-placeholder pattern do_mknod uses) via openat() with - O_NOFOLLOW so a pre-planted symlink at the basename can't redirect - the file creation -- top-level paths included (the previous code fell - through to the bare-path do_mknod() there, whose plain open() followed - such a symlink). On Linux, sockets are recreated with mknodat() like any - other special file; on systems where mknod() can't create sockets the - at-variant fails instead of re-resolving an unsafe parent. -*/ -int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) -{ - /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so - * the at-variant won't build there; fall back to do_mknod() (#896). */ -#if defined(AT_FDCWD) && defined(HAVE_MKNODAT) - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd = AT_FDCWD, ret, e; - BOOL owns = False; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { - if (vfs_symlink_optout_allowed()) - return do_mknod(pathname, mode, dev); - dfd = vfs_owner_walk_parent(pathname, &bname); - if (dfd < 0) - return -1; - if (am_root < 0) { - /* Fake-super represents a special file with an inert regular - * placeholder. Keep that representation when the destination - * is an operator path, but create it relative to the verified - * parent so the confinement guarantee is unchanged. */ - int fd = openat(dfd, bname, - O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, - S_IWUSR | S_IRUSR); - ret = fd < 0 ? -1 : close(fd); - } else { - ret = mknodat(dfd, bname, mode, dev); - } - if (ret < 0 && am_root >= 0) { - /* mknodat() can't make a FIFO/socket on the BSDs/macOS/ - * Solaris (EINVAL); retry race-safely on the held dirfd, - * mirroring the secure-relpath path below. Without this a - * FIFO backup to an operator --backup-dir fails there. */ -#ifdef HAVE_MKFIFOAT - if (S_ISFIFO(mode)) - ret = mkfifoat(dfd, bname, mode); - else -#endif - if (S_ISSOCK(mode)) - errno = EOPNOTSUPP; /* no dirfd-relative socket bind */ - } - e = errno; - close(dfd); - errno = e; - return ret; - } -#endif - - if (!vfs_relpath_active()) - return do_mknod(pathname, mode, dev); - - if (!pathname || !*pathname || *pathname == '/') - return do_mknod(pathname, mode, dev); - - /* A path with a slash needs vfs_resolve_open to confine its - * parent resolution; a top-level path lives in CWD (AT_FDCWD) with - * no parent to subvert. The final component is protected below - * regardless. */ - slash = strrchr(pathname, '/'); - if (slash) { - dlen = slash - pathname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, pathname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - owns = True; - } else { - bname = pathname; - } - - if (am_root < 0) { - /* For --fake-super, do_mknod creates a regular empty - * file as a placeholder for the special-file metadata - * (which is stored in xattrs elsewhere). Do that against - * the (secure or AT_FDCWD) dirfd, with O_NOFOLLOW so a - * pre-planted symlink at the basename can't redirect the - * file creation. */ - int fd = openat(dfd, bname, - O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, - S_IWUSR | S_IRUSR); - if (fd < 0) { - e = errno; - if (owns) close(dfd); - errno = e; - return -1; - } - ret = (close(fd) < 0) ? -1 : 0; - e = errno; - if (owns) close(dfd); - errno = e; - return ret; - } - - /* Try mknodat first (handles every type on Linux); on failure retry - * race-safely with the type-specific primitive. Decided per call -- - * the capability is filesystem-dependent (see do_mknod()). */ - ret = mknodat(dfd, bname, mode, dev); - if (ret < 0) { -#ifdef HAVE_MKFIFOAT - if (S_ISFIFO(mode)) - ret = mkfifoat(dfd, bname, mode); - else -#endif - if (S_ISSOCK(mode)) { - /* There is no dirfd-relative socket bind without - * /proc/self/fd: a top-level path can bind via - * do_mknod(), but a nested one fails safe rather than - * re-resolve a potentially unsafe parent. */ - if (dfd == AT_FDCWD) - ret = do_mknod(pathname, mode, dev); - else - errno = EOPNOTSUPP; - } - /* else: regular/device node -- keep mknodat()'s errno */ - } - e = errno; - if (owns) close(dfd); - errno = e; - return ret; -#else - return do_mknod(pathname, mode, dev); -#endif -} @@ -788,53 +571,5 @@ int do_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) -int do_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (am_root < 0) { - /* --fake-super: regular empty placeholder file (O_NOFOLLOW). */ - int fd = openat(dfd, name, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, - S_IWUSR | S_IRUSR); - if (fd < 0) - return -1; - return (close(fd) < 0) ? -1 : 0; - } - - /* Try mknodat first; on failure retry race-safely with the type- - * specific primitive (see do_mknod()). HAVE_MKNODAT, not HAVE_MKNOD: - * older Darwin has mknod() but not mknodat(), so keying off the former - * compiles a call that then fails to link (#161). */ -#ifdef HAVE_MKNODAT - if (mknodat(dfd, name, mode, dev) == 0) - return 0; -#endif -#ifdef HAVE_MKFIFOAT - if (S_ISFIFO(mode)) - return mkfifoat(dfd, name, mode); -#endif - if (S_ISSOCK(mode)) { - /* No dirfd-relative socket bind without /proc/self/fd; fail safe. - * (The generator routes sockets to do_mknod_at(), not here.) */ - errno = EOPNOTSUPP; - return -1; - } -#ifdef HAVE_MKNODAT - return -1; /* mknodat()'s errno (regular/device node) */ -#else - /* Must match the guard above: reporting "mknodat()'s errno" where the - * call was never compiled would return a stale errno. */ - (void)dev; - errno = ENOSYS; - return -1; -#endif -#else - (void)dfd; (void)name; (void)mode; (void)dev; - errno = ENOSYS; - return -1; -#endif -} diff --git a/t_symlink_secure.c b/t_symlink_secure.c index e30cf219d..e8e730070 100644 --- a/t_symlink_secure.c +++ b/t_symlink_secure.c @@ -1,8 +1,8 @@ /* - * Test harness for the fake-super branches of vfs_symlink_at()/do_mknod_at(). + * Test harness for the fake-super branches of vfs_symlink_at()/vfs_mknod_at(). * Fake-super stores a symlink/device as a placeholder file, so the create * resolves the final component; the no-slash branch used to fall back to - * vfs_symlink()/do_mknod(), whose plain open() followed a planted basename + * vfs_symlink()/vfs_mknod(), whose plain open() followed a planted basename * symlink and escaped the module. Checks the fixed wrappers refuse it; * --poc shows the old fallback escaping. Not linked into rsync. GPL version 2. */ @@ -118,15 +118,15 @@ int main(int argc, char **argv) am_root = -1; /* fake-super: symlinks/devices stored as files */ if (poc) { - /* Pre-fix fallback: a no-slash path went to vfs_symlink()/do_mknod(), + /* Pre-fix fallback: a no-slash path went to vfs_symlink()/vfs_mknod(), * which open() the basename without O_NOFOLLOW. */ #ifdef TEST_SYMLINK_PLACEHOLDER vfs_symlink("VULN_SYM_PAYLOAD", "sympath"); check_clobbered("poc vfs_symlink bare", "../outside/secret_sym", "VULN_SYM_PAYLOAD"); #endif - do_mknod("nodpath", S_IFCHR | 0600, 0); - check_clobbered("poc do_mknod bare", "../outside/secret_nod", ""); + vfs_mknod("nodpath", S_IFCHR | 0600, 0); + check_clobbered("poc vfs_mknod bare", "../outside/secret_nod", ""); return errs ? 1 : 0; } @@ -141,17 +141,11 @@ int main(int argc, char **argv) check_preserved("vfs_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2"); #endif -# ifdef HAVE_MKNODAT - /* Without mknodat() do_mknod_at() IS do_mknod(): the confinement is - * compiled out by design (SECURITY.md), so these would assert a - * property the build deliberately does not have. The do_symlink_at() - * checks above do not depend on it and still run. */ - do_mknod_at("nodpath", S_IFCHR | 0600, 0); - check_preserved("do_mknod_at bare", "../outside/secret_nod", "VICTIM_NOD"); + vfs_mknod_at("nodpath", S_IFCHR | 0600, 0); + check_preserved("vfs_mknod_at bare", "../outside/secret_nod", "VICTIM_NOD"); - do_mknod_at("sub/nodpath2", S_IFCHR | 0600, 0); - check_preserved("do_mknod_at slashed", "../outside/secret_nod2", "VICTIM_NOD2"); -# endif + vfs_mknod_at("sub/nodpath2", S_IFCHR | 0600, 0); + check_preserved("vfs_mknod_at slashed", "../outside/secret_nod2", "VICTIM_NOD2"); if (errs) fprintf(stderr, "%d failure(s)\n", errs); diff --git a/vfs/mknod.c b/vfs/mknod.c new file mode 100644 index 000000000..38dc78f0f --- /dev/null +++ b/vfs/mknod.c @@ -0,0 +1,261 @@ +/* + * vfs/mknod.c - device/fifo/socket node creation wrappers. + * + * Includes the HAVE_MKNOD/HAVE_MKNODAT/HAVE_MKFIFO and AF_UNIX socket-bind + * fallbacks and the fake-super placeholder handling. Moved verbatim out of + * syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" +#ifdef HAVE_SYS_UN_H +#include /* for the socket+bind() fallback in vfs_mknod() */ +#endif + +int vfs_mknod(const char *pathname, mode_t mode, dev_t dev) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(pathname); + + /* For --fake-super, we create a normal file with mode 0600. */ + if (am_root < 0) { + int fd = open(pathname, O_WRONLY|O_CREAT|O_TRUNC, S_IWUSR|S_IRUSR); + if (fd < 0 || close(fd) < 0) + return -1; + return 0; + } + + /* Try mknod first: it handles every node type on Linux. Only if it + * can't make this type on this filesystem (sockets on the BSDs/macOS/ + * Solaris, or an old system lacking FIFO support) do we retry with the + * type-specific primitive. That capability is filesystem-dependent, so + * it is decided per call -- not cached, not probed at build time. */ +#ifdef HAVE_MKNOD + if (mknod(pathname, mode, dev) == 0) + return 0; +#endif +#ifdef HAVE_MKFIFO + if (S_ISFIFO(mode)) + return mkfifo(pathname, mode); +#endif +#ifdef HAVE_SYS_UN_H + if (S_ISSOCK(mode)) { + int sock; + struct sockaddr_un saddr; + unsigned int len = strlcpy(saddr.sun_path, pathname, sizeof saddr.sun_path); + if (len >= sizeof saddr.sun_path) { + errno = ENAMETOOLONG; + return -1; + } +#ifdef HAVE_SOCKADDR_UN_LEN + saddr.sun_len = len + 1; +#endif + saddr.sun_family = AF_UNIX; + + if ((sock = socket(PF_UNIX, SOCK_STREAM, 0)) < 0 + || (unlink(pathname) < 0 && errno != ENOENT) + || (bind(sock, (struct sockaddr*)&saddr, sizeof saddr)) < 0) + return -1; + close(sock); +#ifdef HAVE_CHMOD + return vfs_chmod(pathname, mode); +#else + return 0; +#endif + } +#endif +#ifdef HAVE_MKNOD + return -1; /* mknod() failed for a regular/device node; errno is set */ +#else + errno = ENOSYS; + return -1; +#endif +} + +/* + Symlink-race-safe variant of vfs_mknod() for receiver-side use. See + the comment on vfs_chmod_at() for the threat model. Defence: open + the parent of pathname under vfs_resolve_open() and use + mknodat() against that dirfd. mknodat() covers both regular-file + (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. + + A top-level (no-slash) pathname has no parent to confine, so it uses + AT_FDCWD; the final component is still protected (mknodat/mkfifoat do + not follow it, and the fake-super openat() uses O_NOFOLLOW). + + Fake-super (am_root < 0) is handled inline against the (secure or + AT_FDCWD) dirfd: it creates a regular empty file (the same file-as- + metadata-placeholder pattern vfs_mknod uses) via openat() with + O_NOFOLLOW so a pre-planted symlink at the basename can't redirect + the file creation -- top-level paths included (the previous code fell + through to the bare-path vfs_mknod() there, whose plain open() followed + such a symlink). On Linux, sockets are recreated with mknodat() like any + other special file; on systems where mknod() can't create sockets the + at-variant fails instead of re-resolving an unsafe parent. +*/ +int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) +{ + /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so + * the at-variant won't build there; fall back to vfs_mknod() (#896). */ +#if defined(AT_FDCWD) && defined(HAVE_MKNODAT) + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd = AT_FDCWD, ret, e; + BOOL owns = False; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (vfs.operator_path_resolve) { + if (vfs_symlink_optout_allowed()) + return vfs_mknod(pathname, mode, dev); + dfd = vfs_owner_walk_parent(pathname, &bname); + if (dfd < 0) + return -1; + ret = mknodat(dfd, bname, mode, dev); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + + if (!vfs_relpath_active()) + return vfs_mknod(pathname, mode, dev); + + if (!pathname || !*pathname || *pathname == '/') + return vfs_mknod(pathname, mode, dev); + + /* A path with a slash needs vfs_resolve_open to confine its + * parent resolution; a top-level path lives in CWD (AT_FDCWD) with + * no parent to subvert. The final component is protected below + * regardless. */ + slash = strrchr(pathname, '/'); + if (slash) { + dlen = slash - pathname; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, pathname, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + owns = True; + } else { + bname = pathname; + } + + if (am_root < 0) { + /* For --fake-super, vfs_mknod creates a regular empty + * file as a placeholder for the special-file metadata + * (which is stored in xattrs elsewhere). Do that against + * the (secure or AT_FDCWD) dirfd, with O_NOFOLLOW so a + * pre-planted symlink at the basename can't redirect the + * file creation. */ + int fd = openat(dfd, bname, + O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IWUSR | S_IRUSR); + if (fd < 0) { + e = errno; + if (owns) close(dfd); + errno = e; + return -1; + } + ret = (close(fd) < 0) ? -1 : 0; + e = errno; + if (owns) close(dfd); + errno = e; + return ret; + } + + /* Try mknodat first (handles every type on Linux); on failure retry + * race-safely with the type-specific primitive. Decided per call -- + * the capability is filesystem-dependent (see vfs_mknod()). */ + ret = mknodat(dfd, bname, mode, dev); + if (ret < 0) { +#ifdef HAVE_MKFIFOAT + if (S_ISFIFO(mode)) + ret = mkfifoat(dfd, bname, mode); + else +#endif + if (S_ISSOCK(mode)) { + /* There is no dirfd-relative socket bind without + * /proc/self/fd: a top-level path can bind via + * vfs_mknod(), but a nested one fails safe rather than + * re-resolve a potentially unsafe parent. */ + if (dfd == AT_FDCWD) + ret = vfs_mknod(pathname, mode, dev); + else + errno = EOPNOTSUPP; + } + /* else: regular/device node -- keep mknodat()'s errno */ + } + e = errno; + if (owns) close(dfd); + errno = e; + return ret; +#else + return vfs_mknod(pathname, mode, dev); +#endif +} + +int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) +{ +#ifdef AT_FDCWD + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (am_root < 0) { + /* --fake-super: regular empty placeholder file (O_NOFOLLOW). */ + int fd = openat(dfd, name, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IWUSR | S_IRUSR); + if (fd < 0) + return -1; + return (close(fd) < 0) ? -1 : 0; + } + + /* Try mknodat first; on failure retry race-safely with the type- + * specific primitive (see vfs_mknod()). */ +#ifdef HAVE_MKNOD + if (mknodat(dfd, name, mode, dev) == 0) + return 0; +#endif +#ifdef HAVE_MKFIFOAT + if (S_ISFIFO(mode)) + return mkfifoat(dfd, name, mode); +#endif + if (S_ISSOCK(mode)) { + /* No dirfd-relative socket bind without /proc/self/fd; fail safe. + * (The generator routes sockets to vfs_mknod_at(), not here.) */ + errno = EOPNOTSUPP; + return -1; + } +#ifdef HAVE_MKNOD + return -1; /* mknodat()'s errno (regular/device node) */ +#else + (void)dev; + errno = ENOSYS; + return -1; +#endif +#else + (void)dfd; (void)name; (void)mode; (void)dev; + errno = ENOSYS; + return -1; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 58176d926..380fc00ff 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -136,4 +136,9 @@ int vfs_lchown(const char *path, uid_t owner, gid_t group); int vfs_lchown_at(const char *fname, uid_t owner, gid_t group); int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group); +/* device/fifo/socket node creation (vfs/mknod.c). */ +int vfs_mknod(const char *pathname, mode_t mode, dev_t dev); +int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev); +int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev); + #endif /* RSYNC_VFS_H */ From 7f2ebc55f428162ed4a1b625795e370cc92ea670 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:14:19 +1000 Subject: [PATCH 20/69] vfs: move the times family into vfs/times.c Relocate the timestamp wrappers (do_utimensat/_at/_atfd, do_lutimes, do_utimes, do_utime) and the crtime paths (do_setattrlist_times/_crtime, get_create_time, do_SetFileTime) out of syscall.c into vfs/times.c as the vfs_* names. The struct create_time / #pragma pack / Cygwin windows.h and sys/attr.h includes travel with them, as do the SUPPORT_CRTIMES / HAVE_SETATTRLIST / HAVE_GETATTRLIST / HAVE_UTIMENSAT / HAVE_LUTIMES / HAVE_UTIMES / HAVE_UTIME guards. No behavior change. (Portability- sensitive; wants a fleettest.) --- Makefile.in | 2 +- flist.c | 2 +- generator.c | 4 +- rsync.c | 6 +- syscall.c | 375 ---------------------------------------------------- tls.c | 4 +- util1.c | 12 +- vfs/times.c | 355 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 12 ++ 9 files changed, 382 insertions(+), 390 deletions(-) create mode 100644 vfs/times.c diff --git a/Makefile.in b/Makefile.in index 7eecddca7..a9f4d89f8 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/flist.c b/flist.c index 2a7cf7255..55f3eebda 100644 --- a/flist.c +++ b/flist.c @@ -1675,7 +1675,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, F_ATIME(file) = st.st_atime; #ifdef SUPPORT_CRTIMES if (crtimes_ndx) - F_CRTIME(file) = get_create_time(fname, &st); + F_CRTIME(file) = vfs_get_create_time(fname, &st); #endif if (basename != thisname) diff --git a/generator.c b/generator.c index cf4ed31ae..93d4e693e 100644 --- a/generator.c +++ b/generator.c @@ -413,7 +413,7 @@ static inline int any_time_differs(stat_x *sxp, struct file_struct *file, UNUSED #ifdef SUPPORT_CRTIMES if (!differs && crtimes_ndx) { if (sxp->crtime == 0) - sxp->crtime = get_create_time(fname, &sxp->st); + sxp->crtime = vfs_get_create_time(fname, &sxp->st); differs = !same_time(sxp->crtime, 0, F_CRTIME(file), 0); } #endif @@ -539,7 +539,7 @@ void itemize(const char *fnamecmp, struct file_struct *file, int ndx, int statre #ifdef SUPPORT_CRTIMES if (crtimes_ndx) { if (sxp->crtime == 0) - sxp->crtime = get_create_time(fnamecmp, &sxp->st); + sxp->crtime = vfs_get_create_time(fnamecmp, &sxp->st); if (!same_time(sxp->crtime, 0, F_CRTIME(file), 0)) iflags |= ITEM_REPORT_CRTIME; } diff --git a/rsync.c b/rsync.c index 1d1f1e6b0..1e6467236 100644 --- a/rsync.c +++ b/rsync.c @@ -749,13 +749,13 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if (crtimes_ndx && !(flags & ATTRS_SKIP_CRTIME)) { time_t file_crtime = F_CRTIME(file); if (sxp->crtime == 0) - sxp->crtime = get_create_time(fname, &sxp->st); + sxp->crtime = vfs_get_create_time(fname, &sxp->st); if (!same_time(sxp->crtime, 0L, file_crtime, 0L)) { if ( #ifdef HAVE_GETATTRLIST - do_setattrlist_crtime(fname, file_crtime) == 0 + vfs_setattrlist_crtime(fname, file_crtime) == 0 #elif defined __CYGWIN__ - do_SetFileTime(fname, file_crtime) == 0 + vfs_SetFileTime(fname, file_crtime) == 0 #else #error Unknown crtimes implementation #endif diff --git a/syscall.c b/syscall.c index ee3025b59..706b97a8a 100644 --- a/syscall.c +++ b/syscall.c @@ -54,18 +54,6 @@ # endif #endif -#ifdef SUPPORT_CRTIMES -#ifdef HAVE_GETATTRLIST -#pragma pack(push, 4) -struct create_time { - uint32 length; - struct timespec crtime; -}; -#pragma pack(pop) -#elif defined __CYGWIN__ -#include -#endif -#endif @@ -104,296 +92,6 @@ OFF_T do_lseek(int fd, OFF_T offset, int whence) #endif } -#ifdef HAVE_SETATTRLIST -int do_setattrlist_times(const char *path, STRUCT_STAT *stp) -{ - struct attrlist attrList; - struct timespec ts[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - /* setattrlist() takes a raw path and follows parent symlinks - * (FSOPT_NOFOLLOW only blocks the final component). When hardened - * resolution is active -- vfs_relpath_active(): any non-chroot - * daemon/receiver module, plus a /./ inner-module chroot -- return - * ENOSYS so set_times()' tier walk falls through to do_utimensat_at(), - * which routes the update through a secure parent dirfd. The attribute - * set this would have used (ATTR_CMN_MODTIME / ATTR_CMN_ACCTIME) is the - * same set utimensat() handles, so no functionality is lost. */ - if (vfs_relpath_active()) { - errno = ENOSYS; - return -1; - } - - /* Yes, this is in the opposite order of utime and similar. */ - ts[0].tv_sec = stp->st_mtime; - ts[0].tv_nsec = stp->ST_MTIME_NSEC; - - ts[1].tv_sec = stp->st_atime; - ts[1].tv_nsec = stp->ST_ATIME_NSEC; - - memset(&attrList, 0, sizeof attrList); - attrList.bitmapcount = ATTR_BIT_MAP_COUNT; - attrList.commonattr = ATTR_CMN_MODTIME | ATTR_CMN_ACCTIME; - return setattrlist(path, &attrList, ts, sizeof ts, FSOPT_NOFOLLOW); -} - -#ifdef SUPPORT_CRTIMES -int do_setattrlist_crtime(const char *path, time_t crtime) -{ - struct attrlist attrList; - struct timespec ts; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - /* setattrlist() is path-based and follows parent symlinks - * (FSOPT_NOFOLLOW only blocks the final component), and macOS has no - * at-aware variant targeting ATTR_CMN_CRTIME. As with POSIX ACLs where - * the OS offers no race-safe primitive, we keep --crtimes functional - * (daemon and non-daemon) and accept the parent-component symlink race - * as a documented residual rather than dropping crtime. A daemon - * operator who does not want that residual can disable the feature with - * "refuse options = crtimes" in rsyncd.conf. */ - ts.tv_sec = crtime; - ts.tv_nsec = 0; - - memset(&attrList, 0, sizeof attrList); - attrList.bitmapcount = ATTR_BIT_MAP_COUNT; - attrList.commonattr = ATTR_CMN_CRTIME; - return setattrlist(path, &attrList, &ts, sizeof ts, FSOPT_NOFOLLOW); -} -#endif -#endif /* HAVE_SETATTRLIST */ - -#ifdef SUPPORT_CRTIMES -time_t get_create_time(const char *path, STRUCT_STAT *stp) -{ -#ifdef HAVE_GETATTRLIST - static struct create_time attrBuf; - struct attrlist attrList; - - (void)stp; - /* getattrlist() is path-based and follows parent symlinks; like - * do_setattrlist_crtime() there is no race-safe variant, so reading the - * source crtime stays functional and the parent-component symlink race - * is an accepted residual (refusable via "refuse options = crtimes"). */ - memset(&attrList, 0, sizeof attrList); - attrList.bitmapcount = ATTR_BIT_MAP_COUNT; - attrList.commonattr = ATTR_CMN_CRTIME; - if (getattrlist(path, &attrList, &attrBuf, sizeof attrBuf, FSOPT_NOFOLLOW) < 0) - return 0; - return attrBuf.crtime.tv_sec; -#elif defined __CYGWIN__ - (void)path; - return stp->st_birthtime; -#else -#error Unknown crtimes implementation -#endif -} - -#if defined __CYGWIN__ -int do_SetFileTime(const char *path, time_t crtime) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - int cnt = MultiByteToWideChar(CP_UTF8, 0, path, -1, NULL, 0); - if (cnt == 0) - return -1; - WCHAR *pathw = new_array(WCHAR, cnt); - if (!pathw) - return -1; - MultiByteToWideChar(CP_UTF8, 0, path, -1, pathw, cnt); - HANDLE handle = CreateFileW(pathw, FILE_WRITE_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); - free(pathw); - if (handle == INVALID_HANDLE_VALUE) - return -1; - int64 temp_time = (crtime * 10000000LL) + 116444736000000000LL; - FILETIME birth_time; - birth_time.dwLowDateTime = (DWORD)temp_time; - birth_time.dwHighDateTime = (DWORD)(temp_time >> 32); - int ok = SetFileTime(handle, &birth_time, NULL, NULL); - CloseHandle(handle); - return ok ? 0 : -1; -} -#endif -#endif /* SUPPORT_CRTIMES */ - -#ifdef HAVE_UTIMENSAT -int do_utimensat(const char *path, STRUCT_STAT *stp) -{ - struct timespec t[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_nsec = stp->ST_ATIME_NSEC; -#else - t[0].tv_nsec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_nsec = stp->ST_MTIME_NSEC; -#else - t[1].tv_nsec = 0; -#endif - return utimensat(AT_FDCWD, path, t, AT_SYMLINK_NOFOLLOW); -} - -/* - Symlink-race-safe variant of do_utimensat() for receiver-side use. - See the comment on vfs_chmod_at() for the threat model. utimes() - resolves parent components and follows a final-component symlink; - lutimes() doesn't follow the final component but still resolves - parents. Either way, a parent-symlink swap can redirect the - timestamp update outside the module. Defence: open the parent of - path under vfs_resolve_open() and call utimensat() with - AT_SYMLINK_NOFOLLOW against that dirfd. - - Falls through to do_utimensat() in the same dry-run / non-daemon / - chrooted / no-parent / absolute-path cases as the other wrappers. - Returns -1 with errno=ENOSYS on systems without utimensat() - (caller is expected to fall back to the legacy tier walk). -*/ -int do_utimensat_at(const char *path, STRUCT_STAT *stp) -{ -#ifdef AT_FDCWD - struct timespec t[2]; - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (!vfs_relpath_active()) - return do_utimensat(path, stp); - - if (!path || !*path || *path == '/') - return do_utimensat(path, stp); - - slash = strrchr(path, '/'); - if (!slash) - return do_utimensat(path, stp); - - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_nsec = stp->ST_ATIME_NSEC; -#else - t[0].tv_nsec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_nsec = stp->ST_MTIME_NSEC; -#else - t[1].tv_nsec = 0; -#endif - - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; - - ret = utimensat(dfd, bname, t, AT_SYMLINK_NOFOLLOW); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return do_utimensat(path, stp); -#endif -} -#endif - -#ifdef HAVE_LUTIMES -int do_lutimes(const char *path, STRUCT_STAT *stp) -{ - struct timeval t[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_usec = stp->ST_ATIME_NSEC / 1000; -#else - t[0].tv_usec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_usec = stp->ST_MTIME_NSEC / 1000; -#else - t[1].tv_usec = 0; -#endif - return lutimes(path, t); -} -#endif - -#ifdef HAVE_UTIMES -int do_utimes(const char *path, STRUCT_STAT *stp) -{ - struct timeval t[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_usec = stp->ST_ATIME_NSEC / 1000; -#else - t[0].tv_usec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_usec = stp->ST_MTIME_NSEC / 1000; -#else - t[1].tv_usec = 0; -#endif - return utimes(path, t); -} - -#elif defined HAVE_UTIME -int do_utime(const char *path, STRUCT_STAT *stp) -{ -#ifdef HAVE_STRUCT_UTIMBUF - struct utimbuf tbuf; -#else - time_t t[2]; -#endif - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - -# ifdef HAVE_STRUCT_UTIMBUF - tbuf.actime = stp->st_atime; - tbuf.modtime = stp->st_mtime; - return utime(path, &tbuf); -# else - t[0] = stp->st_atime; - t[1] = stp->st_mtime; - return utime(path, t); -# endif -} - -#else -#error Need utimes or utime function. -#endif #ifdef SUPPORT_PREALLOCATION #ifdef FALLOC_FL_KEEP_SIZE @@ -495,79 +193,6 @@ int do_punch_hole(int fd, OFF_T pos, OFF_T len) -/* Mode/owner on an already-open fd (no path, no symlink to follow): the - * race-free way to set metadata on a cross-tree operator-path leaf that was - * pinned with O_NOFOLLOW. See set_file_attrs(). */ -int do_fchown(int fd, uid_t owner, gid_t group) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return fchown(fd, owner, group); -} - -#ifdef HAVE_CHMOD -int do_fchmod(int fd, mode_t mode) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return fchmod(fd, mode); -} -#endif - -#ifdef HAVE_FUTIMENS -/* Set times on an already-open fd (the race-free counterpart for a pinned - * cross-tree operator leaf -- see set_file_attrs()). */ -int do_futimens(int fd, STRUCT_STAT *stp) -{ - struct timespec t[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_nsec = stp->ST_ATIME_NSEC; -#else - t[0].tv_nsec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_nsec = stp->ST_MTIME_NSEC; -#else - t[1].tv_nsec = 0; -#endif - return futimens(fd, t); -} -#endif - -#ifdef HAVE_UTIMENSAT -int do_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) -{ -#ifdef AT_FDCWD - struct timespec t[2]; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - t[0].tv_sec = stp->st_atime; -#ifdef ST_ATIME_NSEC - t[0].tv_nsec = stp->ST_ATIME_NSEC; -#else - t[0].tv_nsec = 0; -#endif - t[1].tv_sec = stp->st_mtime; -#ifdef ST_MTIME_NSEC - t[1].tv_nsec = stp->ST_MTIME_NSEC; -#else - t[1].tv_nsec = 0; -#endif - return utimensat(dfd, name, t, AT_SYMLINK_NOFOLLOW); -#else - (void)dfd; (void)name; (void)stp; - errno = ENOSYS; - return -1; -#endif -} -#endif diff --git a/tls.c b/tls.c index 5aff8ecac..42df6ee4f 100644 --- a/tls.c +++ b/tls.c @@ -163,8 +163,8 @@ static void list_file(const char *fname) if (vfs_lstat(fname, &buf) < 0) failed("stat", fname); #ifdef SUPPORT_CRTIMES - if (display_crtimes && (crtime = get_create_time(fname, &buf)) == 0) - failed("get_create_time", fname); + if (display_crtimes && (crtime = vfs_get_create_time(fname, &buf)) == 0) + failed("vfs_get_create_time", fname); #endif #ifdef SUPPORT_XATTRS if (am_root < 0) diff --git a/util1.c b/util1.c index cbe926c4c..aae115e67 100644 --- a/util1.c +++ b/util1.c @@ -130,7 +130,7 @@ int set_times(const char *fname, STRUCT_STAT *stp) switch (switch_step) { #ifdef HAVE_SETATTRLIST #include "case_N.h" - if (do_setattrlist_times(fname, stp) == 0) + if (vfs_setattrlist_times(fname, stp) == 0) break; if (errno != ENOSYS) return -1; @@ -139,7 +139,7 @@ int set_times(const char *fname, STRUCT_STAT *stp) #ifdef HAVE_UTIMENSAT #include "case_N.h" - if (do_utimensat_at(fname, stp) == 0) + if (vfs_utimensat_at(fname, stp) == 0) break; if (errno != ENOSYS) return -1; @@ -148,7 +148,7 @@ int set_times(const char *fname, STRUCT_STAT *stp) #ifdef HAVE_LUTIMES #include "case_N.h" - if (do_lutimes(fname, stp) == 0) + if (vfs_lutimes(fname, stp) == 0) break; if (errno != ENOSYS) return -1; @@ -165,10 +165,10 @@ int set_times(const char *fname, STRUCT_STAT *stp) #include "case_N.h" #ifdef HAVE_UTIMES - if (do_utimes(fname, stp) == 0) + if (vfs_utimes(fname, stp) == 0) break; #else - if (do_utime(fname, stp) == 0) + if (vfs_utime(fname, stp) == 0) break; #endif @@ -186,7 +186,7 @@ int set_times(const char *fname, STRUCT_STAT *stp) int set_times_at(int dfd, const char *name, STRUCT_STAT *stp) { #if defined HAVE_UTIMENSAT && !defined HAVE_SETATTRLIST - int r = do_utimensat_atfd(dfd, name, stp); + int r = vfs_utimensat_atfd(dfd, name, stp); if (r == 0) return 0; if (errno == ENOSYS) diff --git a/vfs/times.c b/vfs/times.c new file mode 100644 index 000000000..08b4f2d94 --- /dev/null +++ b/vfs/times.c @@ -0,0 +1,355 @@ +/* + * vfs/times.c - timestamp-setting wrappers (utimensat/lutimes/utimes/utime) + * plus the macOS setattrlist crtime path and the Cygwin SetFileTime path. + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" +#ifdef HAVE_SYS_ATTR_H +#include +#endif + +#ifdef SUPPORT_CRTIMES +#ifdef HAVE_GETATTRLIST +#pragma pack(push, 4) +struct create_time { + uint32 length; + struct timespec crtime; +}; +#pragma pack(pop) +#elif defined __CYGWIN__ +#include +#endif +#endif + +#ifdef HAVE_SETATTRLIST +int vfs_setattrlist_times(const char *path, STRUCT_STAT *stp) +{ + struct attrlist attrList; + struct timespec ts[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + /* setattrlist() takes a raw path and follows parent symlinks + * (FSOPT_NOFOLLOW only blocks the final component). When hardened + * resolution is active -- vfs_relpath_active(): any non-chroot + * daemon/receiver module, plus a /./ inner-module chroot -- return + * ENOSYS so set_times()' tier walk falls through to vfs_utimensat_at(), + * which routes the update through a secure parent dirfd. The attribute + * set this would have used (ATTR_CMN_MODTIME / ATTR_CMN_ACCTIME) is the + * same set utimensat() handles, so no functionality is lost. */ + if (vfs_relpath_active()) { + errno = ENOSYS; + return -1; + } + + /* Yes, this is in the opposite order of utime and similar. */ + ts[0].tv_sec = stp->st_mtime; + ts[0].tv_nsec = stp->ST_MTIME_NSEC; + + ts[1].tv_sec = stp->st_atime; + ts[1].tv_nsec = stp->ST_ATIME_NSEC; + + memset(&attrList, 0, sizeof attrList); + attrList.bitmapcount = ATTR_BIT_MAP_COUNT; + attrList.commonattr = ATTR_CMN_MODTIME | ATTR_CMN_ACCTIME; + return setattrlist(path, &attrList, ts, sizeof ts, FSOPT_NOFOLLOW); +} + +#ifdef SUPPORT_CRTIMES +int vfs_setattrlist_crtime(const char *path, time_t crtime) +{ + struct attrlist attrList; + struct timespec ts; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + /* setattrlist() is path-based and follows parent symlinks + * (FSOPT_NOFOLLOW only blocks the final component), and macOS has no + * at-aware variant targeting ATTR_CMN_CRTIME. As with POSIX ACLs where + * the OS offers no race-safe primitive, we keep --crtimes functional + * (daemon and non-daemon) and accept the parent-component symlink race + * as a documented residual rather than dropping crtime. A daemon + * operator who does not want that residual can disable the feature with + * "refuse options = crtimes" in rsyncd.conf. */ + ts.tv_sec = crtime; + ts.tv_nsec = 0; + + memset(&attrList, 0, sizeof attrList); + attrList.bitmapcount = ATTR_BIT_MAP_COUNT; + attrList.commonattr = ATTR_CMN_CRTIME; + return setattrlist(path, &attrList, &ts, sizeof ts, FSOPT_NOFOLLOW); +} +#endif +#endif /* HAVE_SETATTRLIST */ + +#ifdef SUPPORT_CRTIMES +time_t vfs_get_create_time(const char *path, STRUCT_STAT *stp) +{ +#ifdef HAVE_GETATTRLIST + static struct create_time attrBuf; + struct attrlist attrList; + + (void)stp; + /* getattrlist() is path-based and follows parent symlinks; like + * vfs_setattrlist_crtime() there is no race-safe variant, so reading the + * source crtime stays functional and the parent-component symlink race + * is an accepted residual (refusable via "refuse options = crtimes"). */ + memset(&attrList, 0, sizeof attrList); + attrList.bitmapcount = ATTR_BIT_MAP_COUNT; + attrList.commonattr = ATTR_CMN_CRTIME; + if (getattrlist(path, &attrList, &attrBuf, sizeof attrBuf, FSOPT_NOFOLLOW) < 0) + return 0; + return attrBuf.crtime.tv_sec; +#elif defined __CYGWIN__ + (void)path; + return stp->st_birthtime; +#else +#error Unknown crtimes implementation +#endif +} + +#if defined __CYGWIN__ +int vfs_SetFileTime(const char *path, time_t crtime) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + int cnt = MultiByteToWideChar(CP_UTF8, 0, path, -1, NULL, 0); + if (cnt == 0) + return -1; + WCHAR *pathw = new_array(WCHAR, cnt); + if (!pathw) + return -1; + MultiByteToWideChar(CP_UTF8, 0, path, -1, pathw, cnt); + HANDLE handle = CreateFileW(pathw, FILE_WRITE_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(pathw); + if (handle == INVALID_HANDLE_VALUE) + return -1; + int64 temp_time = (crtime * 10000000LL) + 116444736000000000LL; + FILETIME birth_time; + birth_time.dwLowDateTime = (DWORD)temp_time; + birth_time.dwHighDateTime = (DWORD)(temp_time >> 32); + int ok = SetFileTime(handle, &birth_time, NULL, NULL); + CloseHandle(handle); + return ok ? 0 : -1; +} +#endif +#endif /* SUPPORT_CRTIMES */ + +#ifdef HAVE_UTIMENSAT +int vfs_utimensat(const char *path, STRUCT_STAT *stp) +{ + struct timespec t[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_nsec = stp->ST_ATIME_NSEC; +#else + t[0].tv_nsec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_nsec = stp->ST_MTIME_NSEC; +#else + t[1].tv_nsec = 0; +#endif + return utimensat(AT_FDCWD, path, t, AT_SYMLINK_NOFOLLOW); +} + +/* + Symlink-race-safe variant of vfs_utimensat() for receiver-side use. + See the comment on vfs_chmod_at() for the threat model. utimes() + resolves parent components and follows a final-component symlink; + lutimes() doesn't follow the final component but still resolves + parents. Either way, a parent-symlink swap can redirect the + timestamp update outside the module. Defence: open the parent of + path under vfs_resolve_open() and call utimensat() with + AT_SYMLINK_NOFOLLOW against that dirfd. + + Falls through to vfs_utimensat() in the same dry-run / non-daemon / + chrooted / no-parent / absolute-path cases as the other wrappers. + Returns -1 with errno=ENOSYS on systems without utimensat() + (caller is expected to fall back to the legacy tier walk). +*/ +int vfs_utimensat_at(const char *path, STRUCT_STAT *stp) +{ +#ifdef AT_FDCWD + struct timespec t[2]; + char dirpath[MAXPATHLEN]; + const char *bname; + const char *slash; + int dfd, ret, e; + size_t dlen; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + if (!vfs_relpath_active()) + return vfs_utimensat(path, stp); + + if (!path || !*path || *path == '/') + return vfs_utimensat(path, stp); + + slash = strrchr(path, '/'); + if (!slash) + return vfs_utimensat(path, stp); + + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_nsec = stp->ST_ATIME_NSEC; +#else + t[0].tv_nsec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_nsec = stp->ST_MTIME_NSEC; +#else + t[1].tv_nsec = 0; +#endif + + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + + ret = utimensat(dfd, bname, t, AT_SYMLINK_NOFOLLOW); + e = errno; + close(dfd); + errno = e; + return ret; +#else + return vfs_utimensat(path, stp); +#endif +} +#endif + +#ifdef HAVE_LUTIMES +int vfs_lutimes(const char *path, STRUCT_STAT *stp) +{ + struct timeval t[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_usec = stp->ST_ATIME_NSEC / 1000; +#else + t[0].tv_usec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_usec = stp->ST_MTIME_NSEC / 1000; +#else + t[1].tv_usec = 0; +#endif + return lutimes(path, t); +} +#endif + +#ifdef HAVE_UTIMES +int vfs_utimes(const char *path, STRUCT_STAT *stp) +{ + struct timeval t[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_usec = stp->ST_ATIME_NSEC / 1000; +#else + t[0].tv_usec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_usec = stp->ST_MTIME_NSEC / 1000; +#else + t[1].tv_usec = 0; +#endif + return utimes(path, t); +} + +#elif defined HAVE_UTIME +int vfs_utime(const char *path, STRUCT_STAT *stp) +{ +#ifdef HAVE_STRUCT_UTIMBUF + struct utimbuf tbuf; +#else + time_t t[2]; +#endif + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + +# ifdef HAVE_STRUCT_UTIMBUF + tbuf.actime = stp->st_atime; + tbuf.modtime = stp->st_mtime; + return utime(path, &tbuf); +# else + t[0] = stp->st_atime; + t[1] = stp->st_mtime; + return utime(path, t); +# endif +} + +#else +#error Need utimes or utime function. +#endif + +#ifdef HAVE_UTIMENSAT +int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) +{ +#ifdef AT_FDCWD + struct timespec t[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_nsec = stp->ST_ATIME_NSEC; +#else + t[0].tv_nsec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_nsec = stp->ST_MTIME_NSEC; +#else + t[1].tv_nsec = 0; +#endif + return utimensat(dfd, name, t, AT_SYMLINK_NOFOLLOW); +#else + (void)dfd; (void)name; (void)stp; + errno = ENOSYS; + return -1; +#endif +} +#endif diff --git a/vfs/vfs.h b/vfs/vfs.h index 380fc00ff..ccb4b6ee4 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -141,4 +141,16 @@ int vfs_mknod(const char *pathname, mode_t mode, dev_t dev); int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev); int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev); +/* timestamp setting + crtimes (vfs/times.c). */ +int vfs_setattrlist_times(const char *path, STRUCT_STAT *stp); +int vfs_setattrlist_crtime(const char *path, time_t crtime); +time_t vfs_get_create_time(const char *path, STRUCT_STAT *stp); +int vfs_SetFileTime(const char *path, time_t crtime); +int vfs_utimensat(const char *path, STRUCT_STAT *stp); +int vfs_utimensat_at(const char *path, STRUCT_STAT *stp); +int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp); +int vfs_lutimes(const char *path, STRUCT_STAT *stp); +int vfs_utimes(const char *path, STRUCT_STAT *stp); +int vfs_utime(const char *path, STRUCT_STAT *stp); + #endif /* RSYNC_VFS_H */ From 2341b7b9a0f2614967cc08999052d451766b47ca Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:16:41 +1000 Subject: [PATCH 21/69] vfs: move the file-data ops into vfs/fileio.c Relocate do_ftruncate/do_lseek/do_fallocate/do_punch_hole out of syscall.c into vfs/fileio.c as the vfs_* names, declared in vfs/vfs.h. The SUPPORT_PREALLOCATION / HAVE_FALLOCATE / HAVE_SYS_FALLOCATE / FALLOC_FL_PUNCH_HOLE guards travel verbatim. This was the last operation family: syscall.c no longer defines any filesystem wrapper. No behavior change. (Portability-sensitive; wants a fleettest.) --- Makefile.in | 2 +- clientserver.c | 2 +- fileio.c | 55 +++++++++++-------- receiver.c | 10 ++-- syscall.c | 117 ----------------------------------------- util1.c | 6 +-- vfs/fileio.c | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 6 +++ 8 files changed, 187 insertions(+), 150 deletions(-) create mode 100644 vfs/fileio.c diff --git a/Makefile.in b/Makefile.in index a9f4d89f8..30b77b6b0 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/clientserver.c b/clientserver.c index cb4e9913d..0a808b80e 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1651,7 +1651,7 @@ static void create_pid_file(void) else if (st1.st_dev != st2.st_dev || st1.st_ino != st2.st_ino) fail = "verify stat info for"; #ifdef HAVE_FTRUNCATE - else if (do_ftruncate(pid_file_fd, 0) < 0) + else if (vfs_ftruncate(pid_file_fd, 0) < 0) fail = "truncate"; #endif else { diff --git a/fileio.c b/fileio.c index c4cf57829..7605f6e02 100644 --- a/fileio.c +++ b/fileio.c @@ -45,17 +45,17 @@ int sparse_end(int f, OFF_T size, int updating_basis_or_equiv) int ret = 0; if (updating_basis_or_equiv) { - if (sparse_seek && do_punch_hole(f, sparse_past_write, sparse_seek) < 0) + if (sparse_seek && vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0) ret = -1; #ifdef HAVE_FTRUNCATE /* A compilation formality -- in-place requires ftruncate() */ else /* Just in case the original file was longer */ - ret = do_ftruncate(f, size); + ret = vfs_ftruncate(f, size); #endif } else if (sparse_seek) { #ifdef HAVE_FTRUNCATE - ret = do_ftruncate(f, size); + ret = vfs_ftruncate(f, size); #else - if (do_lseek(f, sparse_seek-1, SEEK_CUR) != size-1) + if (vfs_lseek(f, sparse_seek-1, SEEK_CUR) != size-1) ret = -1; else { do { @@ -137,23 +137,13 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int if (l1 == len) return len; - /* Scan the middle [l1, len-l2) for interior runs of zeros that are at - * least SPARSE_WRITE_SIZE long (the hole granularity rsync has always - * used) and defer those as holes. Everything in between -- which may - * include shorter zero runs not worth a hole -- is emitted in one go, - * rather than being chopped into SPARSE_WRITE_SIZE-byte pieces, which - * made copying a large non-sparse file cost ~one write() per KiB. - * - * The matched (use_seek) case runs through the same scan: its interior - * zero runs still have to be punched out, which is what --inplace - * --sparse relies on to keep a hole-y basis file sparse. */ - start = l1; - end = len - l2; - for (i = l1; i < end; ) { - int z; - if (buf[i] != 0) { - i++; - continue; + if (sparse_seek) { + if (sparse_past_write >= preallocated_len) { + if (vfs_lseek(f, sparse_seek, SEEK_CUR) < 0) + return -1; + } else if (vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0) { + sparse_seek = 0; + return -1; } for (z = 1; i + z < end && buf[i+z] == 0; z++) {} if (z < SPARSE_WRITE_SIZE) { @@ -177,6 +167,25 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int sparse_seek = l2; sparse_past_write = offset + len - l2; + if (use_seek) { + /* The in-place data already matches. */ + if (vfs_lseek(f, len - (l1+l2), SEEK_CUR) < 0) + return -1; + return len; + } + + while ((ret = write(f, buf + l1, len - (l1+l2))) <= 0) { + if (ret < 0 && errno == EINTR) + continue; + sparse_seek = 0; + return ret; + } + + if (ret != (int)(len - (l1+l2))) { + sparse_seek = 0; + return l1+ret; + } + return len; } @@ -262,7 +271,7 @@ int skip_matched(int fd, OFF_T offset, const char *buf, int len) if (flush_write_file(fd) < 0) return -1; - if ((pos = do_lseek(fd, len, SEEK_CUR)) != offset + len) { + if ((pos = vfs_lseek(fd, len, SEEK_CUR)) != offset + len) { rsyserr(FERROR_XFER, errno, "lseek returned %s, not %s", big_num(pos), big_num(offset)); return -1; @@ -345,7 +354,7 @@ char *map_ptr(struct map_struct *map, OFF_T offset, int32 len) } if (map->p_fd_offset != read_start) { - OFF_T ret = do_lseek(map->fd, read_start, SEEK_SET); + OFF_T ret = vfs_lseek(map->fd, read_start, SEEK_SET); if (ret != read_start) { rsyserr(FERROR, errno, "lseek returned %s, not %s", big_num(ret), big_num(read_start)); diff --git a/receiver.c b/receiver.c index 74a986a0c..2cbf0760b 100644 --- a/receiver.c +++ b/receiver.c @@ -460,14 +460,14 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, if (preallocate_files && fd != -1 && total_size > 0 && (!inplace_sizing || total_size > size_r)) { /* Try to preallocate enough space for file's eventual length. Can * reduce fragmentation on filesystems like ext4, xfs, and NTFS. */ - if ((preallocated_len = do_fallocate(fd, 0, total_size)) < 0) - rsyserr(FWARNING, errno, "do_fallocate %s", full_fname(fname)); + if ((preallocated_len = vfs_fallocate(fd, 0, total_size)) < 0) + rsyserr(FWARNING, errno, "vfs_fallocate %s", full_fname(fname)); } else #endif if (inplace_sizing) { #ifdef HAVE_FTRUNCATE /* The most compatible way to create a sparse file is to start with no length. */ - if (sparse_files > 0 && whole_file && fd >= 0 && do_ftruncate(fd, 0) == 0) + if (sparse_files > 0 && whole_file && fd >= 0 && vfs_ftruncate(fd, 0) == 0) preallocated_len = 0; else #endif @@ -510,7 +510,7 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, } } offset = sum.flength; - if (fd != -1 && (j = do_lseek(fd, offset, SEEK_SET)) != offset) { + if (fd != -1 && (j = vfs_lseek(fd, offset, SEEK_SET)) != offset) { rsyserr(FERROR_XFER, errno, "lseek of %s returned %s, not %s", full_fname(fname), big_num(j), big_num(offset)); exit_cleanup(RERR_FILEIO); @@ -634,7 +634,7 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, * preallocate_files: total_size could have been an overestimate. * Cut off any extra preallocated zeros from dest file. */ if ((inplace_sizing || preallocated_len > offset) && fd != -1 && !IS_DEVICE(file->mode)) { - if (do_ftruncate(fd, offset) < 0) + if (vfs_ftruncate(fd, offset) < 0) rsyserr(FERROR_XFER, errno, "ftruncate failed on %s", full_fname(fname)); } #endif diff --git a/syscall.c b/syscall.c index 706b97a8a..6d7e54fe1 100644 --- a/syscall.c +++ b/syscall.c @@ -65,123 +65,6 @@ -#ifdef HAVE_FTRUNCATE -int do_ftruncate(int fd, OFF_T size) -{ - int ret; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - do { - ret = ftruncate(fd, size); - } while (ret < 0 && errno == EINTR); - - return ret; -} -#endif - - - -OFF_T do_lseek(int fd, OFF_T offset, int whence) -{ -#ifdef HAVE_LSEEK64 - return lseek64(fd, offset, whence); -#else - return lseek(fd, offset, whence); -#endif -} - - -#ifdef SUPPORT_PREALLOCATION -#ifdef FALLOC_FL_KEEP_SIZE -#define DO_FALLOC_OPTIONS FALLOC_FL_KEEP_SIZE -#else -#define DO_FALLOC_OPTIONS 0 -#endif - -OFF_T do_fallocate(int fd, OFF_T offset, OFF_T length) -{ - /* FALLOC_FL_KEEP_SIZE lets --preallocate/--inplace keep the file size at 0 - * until data is written, but a later hole-punch (for --sparse) can only - * deallocate blocks that lie within the file's size -- with KEEP_SIZE the - * reserved blocks sit beyond EOF and the punch silently does nothing, - * leaving the file fully allocated. So when holes will also be punched, - * preallocate at full size instead (write_sparse then punches the nulls). */ - int opts = (inplace || preallocate_files) && sparse_files <= 0 ? DO_FALLOC_OPTIONS : 0; - int ret; - RETURN_ERROR_IF(dry_run, 0); - RETURN_ERROR_IF_RO_OR_LO; - if (length & 1) /* make the length not match the desired length */ - length++; - else - length--; -#if defined HAVE_FALLOCATE - ret = fallocate(fd, opts, offset, length); -#elif defined HAVE_SYS_FALLOCATE - ret = syscall(SYS_fallocate, fd, opts, (loff_t)offset, (loff_t)length); -#elif defined HAVE_EFFICIENT_POSIX_FALLOCATE - ret = posix_fallocate(fd, offset, length); -#else -#error Coding error in SUPPORT_PREALLOCATION logic. -#endif - if (ret < 0) - return ret; - if (opts == 0) { - STRUCT_STAT st; - if (vfs_fstat(fd, &st) < 0) - return length; - return st.st_blocks * S_BLKSIZE; - } - /* With FALLOC_FL_KEEP_SIZE the blocks for [0, length) are reserved even - * though the file size stays put. Return that reserved length (not 0) so - * the caller's preallocated_len is meaningful: write_sparse() needs it to - * choose do_punch_hole() over a plain lseek() when turning a null run into - * a hole, and the receiver uses it to trim any over-preallocation. (A - * stray 0 here, from 2019's switch to KEEP_SIZE, is why --preallocate - * --sparse stopped producing sparse files.) */ - return length; -} -#endif - -/* Punch a hole at pos for len bytes. The current file position must be at pos and will be - * changed to be at pos + len. */ -int do_punch_hole(int fd, OFF_T pos, OFF_T len) -{ -#ifdef HAVE_FALLOCATE -# ifdef HAVE_FALLOC_FL_PUNCH_HOLE - if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, pos, len) == 0) { - if (do_lseek(fd, len, SEEK_CUR) != pos + len) - return -1; - return 0; - } -# endif -# ifdef HAVE_FALLOC_FL_ZERO_RANGE - if (fallocate(fd, FALLOC_FL_ZERO_RANGE, pos, len) == 0) { - if (do_lseek(fd, len, SEEK_CUR) != pos + len) - return -1; - return 0; - } -# endif -#else - (void)pos; -#endif - { - char zeros[4096]; - memset(zeros, 0, sizeof zeros); - while (len > 0) { - int chunk = len > (int)sizeof zeros ? (int)sizeof zeros : len; - int wrote = write(fd, zeros, chunk); - if (wrote <= 0) { - if (wrote < 0 && errno == EINTR) - continue; - return -1; - } - len -= wrote; - } - } - return 0; -} /* The logical current directory (maintained by change_dir() in util1.c). diff --git a/util1.c b/util1.c index aae115e67..4ca416cf3 100644 --- a/util1.c +++ b/util1.c @@ -428,9 +428,9 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) if (vfs_fstat(ifd, &srcst) < 0) rsyserr(FWARNING, errno, "fstat %s", full_fname(source)); else if (srcst.st_size > 0) { - prealloc_len = do_fallocate(ofd, 0, srcst.st_size); + prealloc_len = vfs_fallocate(ofd, 0, srcst.st_size); if (prealloc_len < 0) - rsyserr(FWARNING, errno, "do_fallocate %s", full_fname(dest)); + rsyserr(FWARNING, errno, "vfs_fallocate %s", full_fname(dest)); } } #endif @@ -462,7 +462,7 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) #ifdef HAVE_FTRUNCATE /* If we fail to truncate, the dest file may be wrong, so we * must trigger the "partial transfer" error. */ - if (do_ftruncate(ofd, offset) < 0) + if (vfs_ftruncate(ofd, offset) < 0) rsyserr(FERROR_XFER, errno, "ftruncate %s", full_fname(dest)); #else rprintf(FERROR_XFER, "no ftruncate for over-long pre-alloc: %s", full_fname(dest)); diff --git a/vfs/fileio.c b/vfs/fileio.c new file mode 100644 index 000000000..3f854742b --- /dev/null +++ b/vfs/fileio.c @@ -0,0 +1,139 @@ +/* + * vfs/fileio.c - fd-based file-data ops: ftruncate, lseek, fallocate, + * hole-punching. + * + * Moved verbatim out of syscall.c. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" +#if defined HAVE_SYS_FALLOCATE && !defined HAVE_FALLOCATE +#include +#endif + +#ifdef HAVE_FTRUNCATE +int vfs_ftruncate(int fd, OFF_T size) +{ + int ret; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + + do { + ret = ftruncate(fd, size); + } while (ret < 0 && errno == EINTR); + + return ret; +} +#endif + + + +OFF_T vfs_lseek(int fd, OFF_T offset, int whence) +{ +#ifdef HAVE_LSEEK64 + return lseek64(fd, offset, whence); +#else + return lseek(fd, offset, whence); +#endif +} + + +#ifdef SUPPORT_PREALLOCATION +#ifdef FALLOC_FL_KEEP_SIZE +#define DO_FALLOC_OPTIONS FALLOC_FL_KEEP_SIZE +#else +#define DO_FALLOC_OPTIONS 0 +#endif + +OFF_T vfs_fallocate(int fd, OFF_T offset, OFF_T length) +{ + /* FALLOC_FL_KEEP_SIZE lets --preallocate/--inplace keep the file size at 0 + * until data is written, but a later hole-punch (for --sparse) can only + * deallocate blocks that lie within the file's size -- with KEEP_SIZE the + * reserved blocks sit beyond EOF and the punch silently does nothing, + * leaving the file fully allocated. So when holes will also be punched, + * preallocate at full size instead (write_sparse then punches the nulls). */ + int opts = (inplace || preallocate_files) && sparse_files <= 0 ? DO_FALLOC_OPTIONS : 0; + int ret; + RETURN_ERROR_IF(dry_run, 0); + RETURN_ERROR_IF_RO_OR_LO; + if (length & 1) /* make the length not match the desired length */ + length++; + else + length--; +#if defined HAVE_FALLOCATE + ret = fallocate(fd, opts, offset, length); +#elif defined HAVE_SYS_FALLOCATE + ret = syscall(SYS_fallocate, fd, opts, (loff_t)offset, (loff_t)length); +#elif defined HAVE_EFFICIENT_POSIX_FALLOCATE + ret = posix_fallocate(fd, offset, length); +#else +#error Coding error in SUPPORT_PREALLOCATION logic. +#endif + if (ret < 0) + return ret; + if (opts == 0) { + STRUCT_STAT st; + if (vfs_fstat(fd, &st) < 0) + return length; + return st.st_blocks * S_BLKSIZE; + } + /* With FALLOC_FL_KEEP_SIZE the blocks for [0, length) are reserved even + * though the file size stays put. Return that reserved length (not 0) so + * the caller's preallocated_len is meaningful: write_sparse() needs it to + * choose vfs_punch_hole() over a plain lseek() when turning a null run into + * a hole, and the receiver uses it to trim any over-preallocation. (A + * stray 0 here, from 2019's switch to KEEP_SIZE, is why --preallocate + * --sparse stopped producing sparse files.) */ + return length; +} +#endif + +/* Punch a hole at pos for len bytes. The current file position must be at pos and will be + * changed to be at pos + len. */ +int vfs_punch_hole(int fd, OFF_T pos, OFF_T len) +{ +#ifdef HAVE_FALLOCATE +# ifdef HAVE_FALLOC_FL_PUNCH_HOLE + if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, pos, len) == 0) { + if (vfs_lseek(fd, len, SEEK_CUR) != pos + len) + return -1; + return 0; + } +# endif +# ifdef HAVE_FALLOC_FL_ZERO_RANGE + if (fallocate(fd, FALLOC_FL_ZERO_RANGE, pos, len) == 0) { + if (vfs_lseek(fd, len, SEEK_CUR) != pos + len) + return -1; + return 0; + } +# endif +#else + (void)pos; +#endif + { + char zeros[4096]; + memset(zeros, 0, sizeof zeros); + while (len > 0) { + int chunk = len > (int)sizeof zeros ? (int)sizeof zeros : len; + int wrote = write(fd, zeros, chunk); + if (wrote <= 0) { + if (wrote < 0 && errno == EINTR) + continue; + return -1; + } + len -= wrote; + } + } + return 0; +} diff --git a/vfs/vfs.h b/vfs/vfs.h index ccb4b6ee4..6915d66b6 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -153,4 +153,10 @@ int vfs_lutimes(const char *path, STRUCT_STAT *stp); int vfs_utimes(const char *path, STRUCT_STAT *stp); int vfs_utime(const char *path, STRUCT_STAT *stp); +/* fd-based file-data ops (vfs/fileio.c). */ +int vfs_ftruncate(int fd, OFF_T size); +OFF_T vfs_lseek(int fd, OFF_T offset, int whence); +OFF_T vfs_fallocate(int fd, OFF_T offset, OFF_T length); +int vfs_punch_hole(int fd, OFF_T pos, OFF_T len); + #endif /* RSYNC_VFS_H */ From 7c15f78bc3c6df41c94ee1dfa703ed8c5b8db479 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:29:01 +1000 Subject: [PATCH 22/69] vfs: restore platform includes in vfs/chmod.c The chmod family carries macOS setattrlist() and the Linux SYS_fchmodat2 raw-syscall fast path, but the move into vfs/chmod.c dropped the two platform headers that syscall.c had included at file scope: (macOS) and (Linux). A Linux build hid this -- with SYS_fchmodat2 undefined the code silently fell back to fchmodat() -- but macOS failed to compile setattrlist(). Re-add both, matching the original syscall.c includes. Found by fleettest (mac2 BUILD-FAIL). --- vfs/chmod.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vfs/chmod.c b/vfs/chmod.c index eb775d407..269beeb42 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -16,6 +16,12 @@ #include "rsync.h" #include "ifuncs.h" #include "vfs/vfs_internal.h" +#ifdef HAVE_SYS_ATTR_H +#include /* for the macOS setattrlist() chmod path */ +#endif +#ifdef __linux__ +#include /* SYS_fchmodat2 raw-syscall wrapper */ +#endif #ifdef HAVE_CHMOD int vfs_chmod(const char *path, mode_t mode) From 94fa51be8ce573fc6d81662ca941941893c5932a Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 18:54:34 +1000 Subject: [PATCH 23/69] vfs: remove the now-empty syscall.c Every filesystem wrapper has moved into vfs/, so syscall.c held nothing but its includes and an orphaned comment. Delete it and drop syscall.o from the rsync link and from each test-harness object list (they reach the vfs_* symbols through libvfs.a now). The S_BLKSIZE fallback define, used by vfs/fileio.c, moves there. No behavior change. --- Makefile.in | 22 +++++++------- syscall.c | 83 ---------------------------------------------------- vfs/fileio.c | 10 +++++++ 3 files changed, 21 insertions(+), 94 deletions(-) delete mode 100644 syscall.c diff --git a/Makefile.in b/Makefile.in index 30b77b6b0..39d270ec0 100644 --- a/Makefile.in +++ b/Makefile.in @@ -47,7 +47,7 @@ LIBOBJ=lib/wildmatch.o lib/compat.o lib/snprintf.o lib/mdfour.o lib/md5.o \ zlib_OBJS=zlib/deflate.o zlib/inffast.o zlib/inflate.o zlib/inftrees.o \ zlib/trees.o zlib/zutil.o zlib/adler32.o zlib/compress.o zlib/crc32.o OBJS1_NO_MAIN=flist.o rsync.o generator.o receiver.o cleanup.o sender.o exclude.o \ - util1.o util2.o checksum.o match.o syscall.o log.o backup.o delete.o + util1.o util2.o checksum.o match.o log.o backup.o delete.o OBJS1=$(OBJS1_NO_MAIN) main.o OBJS2=options.o io.o compat.o hlink.o token.o uidlist.o socket.o hashtable.o \ usage.o fileio.o batch.o clientname.o chmod.o acls.o xattrs.o @@ -58,7 +58,7 @@ popt_OBJS= popt/popt.o popt/poptconfig.o \ VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a -TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a +TLS_OBJ = tls.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a # Programs we must have to run the test cases CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ @@ -155,11 +155,11 @@ rrsync: support/rrsync $(OBJS): $(HEADERS) $(CHECK_OBJS): $(HEADERS) $(VFS_OBJ): $(HEADERS) -$(VFS_OBJ) syscall.o: vfs/vfs_internal.h +$(VFS_OBJ): vfs/vfs_internal.h tls.o xattrs.o: lib/sysxattrs.h # The VFS layer is bundled into a static archive linked last on every target so -# that moving filesystem code out of syscall.o never breaks a test harness link +# that moving a filesystem family between files never breaks a test harness link # (the linker pulls only the members each program references). libvfs.a: $(VFS_OBJ) rm -f $@ @@ -225,11 +225,11 @@ getgroups$(EXEEXT): getgroups.o getfsdev$(EXEEXT): getfsdev.o $(CC) $(CFLAGS) $(LDFLAGS) -o $@ getfsdev.o $(LIBS) -TRIMSLASH_OBJ = trimslash.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o libvfs.a +TRIMSLASH_OBJ = trimslash.o util2.o t_stub.o lib/compat.o lib/snprintf.o libvfs.a trimslash$(EXEEXT): $(TRIMSLASH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(TRIMSLASH_OBJ) $(LIBS) -T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a +T_UNSAFE_OBJ = t_unsafe.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) @@ -237,7 +237,7 @@ T_IWILDMATCH_OBJ = t_iwildmatch.o lib/wildmatch.o t_iwildmatch$(EXEEXT): $(T_IWILDMATCH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_IWILDMATCH_OBJ) $(LIBS) -T_CLEAN_FNAME_OBJ = t_clean_fname.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a +T_CLEAN_FNAME_OBJ = t_clean_fname.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a t_clean_fname$(EXEEXT): $(T_CLEAN_FNAME_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CLEAN_FNAME_OBJ) $(LIBS) @@ -254,19 +254,19 @@ T_SAFE_ARG_OBJ = t_safe_arg.o t_safe_arg_main.o $(OBJS1_NO_MAIN) $(OBJS2) $(OBJS t_safe_arg$(EXEEXT): $(T_SAFE_ARG_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SAFE_ARG_OBJ) $(LIBS) -T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a +T_CHMOD_SECURE_OBJ = t_chmod_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) -T_RENAME_SECURE_OBJ = t_rename_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a +T_RENAME_SECURE_OBJ = t_rename_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_rename_secure$(EXEEXT): $(T_RENAME_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_RENAME_SECURE_OBJ) $(LIBS) -T_SYMLINK_SECURE_OBJ = t_symlink_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a +T_SYMLINK_SECURE_OBJ = t_symlink_secure.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_symlink_secure$(EXEEXT): $(T_SYMLINK_SECURE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SYMLINK_SECURE_OBJ) $(LIBS) -T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a +T_SECURE_RELPATH_OBJ = t_secure_relpath.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o libvfs.a t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS) diff --git a/syscall.c b/syscall.c deleted file mode 100644 index 6d7e54fe1..000000000 --- a/syscall.c +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Syscall wrappers to ensure that nothing gets done in dry_run mode - * and to handle system peculiarities. - * - * Copyright (C) 1998 Andrew Tridgell - * Copyright (C) 2002 Martin Pool - * Copyright (C) 2003-2022 Wayne Davison - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, visit the http://fsf.org website. - */ - -#include "rsync.h" - -#ifdef HAVE_SYS_UN_H -#include /* for the socket+bind() fallback in vfs_mknod() */ -#endif -#ifdef HAVE_SYS_ATTR_H -#include -#endif - -#if defined HAVE_SYS_FALLOCATE && !defined HAVE_FALLOCATE -#include -#endif - -#ifdef __linux__ -#include /* SYS_fchmodat2 / SYS_fallocate raw-syscall wrappers */ -#endif - -#include "ifuncs.h" -#include "vfs/vfs_internal.h" - - - - - -#ifndef S_BLKSIZE -# if defined hpux || defined __hpux__ || defined __hpux -# define S_BLKSIZE 1024 -# elif defined _AIX && defined _I386 -# define S_BLKSIZE 4096 -# else -# define S_BLKSIZE 512 -# endif -#endif - - - - - - - - - - - - - - -/* The logical current directory (maintained by change_dir() in util1.c). - * Defined here -- rather than in util1.c -- so the test helpers that link - * syscall.o but not util1.o (tls, trimslash) get the definition without a - * weak-symbol fallback, which is not portable to PE/COFF targets (Cygwin). */ - - - - - - - - - - diff --git a/vfs/fileio.c b/vfs/fileio.c index 3f854742b..7424f6530 100644 --- a/vfs/fileio.c +++ b/vfs/fileio.c @@ -20,6 +20,16 @@ #include #endif +#ifndef S_BLKSIZE +# if defined hpux || defined __hpux__ || defined __hpux +# define S_BLKSIZE 1024 +# elif defined _AIX && defined _I386 +# define S_BLKSIZE 4096 +# else +# define S_BLKSIZE 512 +# endif +#endif + #ifdef HAVE_FTRUNCATE int vfs_ftruncate(int fd, OFF_T size) { From b4417876b42d6a8d032f28b6b9506b51cf5aa62a Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 19:00:53 +1000 Subject: [PATCH 24/69] vfs: document the layer contract in vfs/vfs.h Refresh the header comment now that the wrappers are vfs_* and syscall.c is gone, and document the three operation forms callers choose between: the plain path wrapper, the parent-resolved _at form (race-safe receiver path), and the _atfd form (single component under a pinned dirfd). Comment-only; no code change. Validated at protocol 30 and 29. --- vfs/vfs.h | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/vfs/vfs.h b/vfs/vfs.h index 6915d66b6..fcf13b825 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -1,12 +1,24 @@ /* * vfs/vfs.h - public interface to rsync's virtual filesystem layer. * - * The VFS owns the messy, security-critical filesystem details (the do_* + * The VFS owns the messy, security-critical filesystem details (the vfs_* * syscall wrappers, the race-safe path resolver, the held-dirfd cache, the * operator-path ownership walk and the daemon module confinement) so the - * mainline protocol/transfer code can stay clean. State that used to be - * scattered across syscall.c statics and clientserver.c externs lives in the - * single global "struct vfs vfs" below. + * mainline protocol/transfer code can stay clean. The state lives in the + * single global "struct vfs vfs" below; the implementations are in the + * per-concern vfs/*.c files (one per operation family, plus the resolver, + * dirstack, dircache and owner-walk cores). + * + * Most operations come in up to three forms, sharing one leaf behaviour: + * vfs_(path, ...) - operate on a path as given (the plain wrapper; + * honours dry-run / read-only). + * vfs__at(path, ...) - resolve the parent components race-safely + * (vfs_resolve_open) and act on the leaf with + * *at()/O_NOFOLLOW; the receiver's TOCTOU-safe + * form, used when vfs_relpath_active(). + * vfs__atfd(dfd, name, ) - act on a single component `name` under an + * already-pinned dirfd (from vfs_opendir / + * vfs_get_dirfd); no path resolution. * * This header is included by rsync.h (just after proto.h) so every translation * unit sees the vfs_* API. It must not include rsync.h itself. From 068c086287921da292ecf48f742d344bc14d8d2b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 19:05:19 +1000 Subject: [PATCH 25/69] vfs: snapshot the daemon module root into struct vfs Fold the served module root (module_dir / module_dirlen / module_dirfd) into the vfs.module_* snapshot so the confinement checks read the VFS's own state rather than implicit clientserver.c externs. clientserver.c calls vfs_set_module_root() in two stages, matching when the values become final: once right after the module path is settled (before the daemon opens any operator path -- filter/include files, the log file), with the root dirfd still -1, and again once that dirfd is pinned by identity. The dirfd is borrowed (open_anchor_dirfd dup()s it); the VFS never closes it. vfs_init() now clears the whole snapshot so a forked connection can never inherit a stale module root. The vfs/ readers (dirstack, secure_open, owner_walk) switch from the externs to vfs.module_*; clientserver/flist/main keep their own globals. Behavior is identical: between the two calls vfs.module_dirfd is -1, so open_anchor_dirfd re-resolves the path exactly as before the pin. --- clientserver.c | 11 +++++++---- vfs/dirstack.c | 16 ++++++++-------- vfs/owner_walk.c | 4 ++-- vfs/secure_open.c | 28 +++++++++++++--------------- vfs/vfs.c | 20 ++++++++++++++++---- vfs/vfs.h | 6 +++++- vfs/vfs_internal.h | 3 --- 7 files changed, 51 insertions(+), 37 deletions(-) diff --git a/clientserver.c b/clientserver.c index 0a808b80e..2f92d44db 100644 --- a/clientserver.c +++ b/clientserver.c @@ -925,10 +925,11 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char } else set_filter_dir(module_dir, module_dirlen); - /* Everything loaded from here to the end of the exclude block is the - * operator's own configuration, so it keeps the ownership walk without the - * module-confinement parse_filter_file() applies to peer-driven merges. */ - daemon_config_filter_file = 1; + /* Snapshot the module root for the VFS confinement checks now that the + * path is final. The root dirfd is pinned later (below); this first call + * must precede any VFS open of an operator-supplied path -- the filter/ + * include files just below, and the log file -- so they see the boundary. */ + vfs_set_module_root(module_dir, module_dirlen, -1); p = lp_filter(module_id); parse_filter_str(&daemon_filter_list, p, rule_template(FILTRULE_WORD_SPLIT), @@ -1064,6 +1065,8 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char #if defined HAVE_FDOPENDIR && defined O_DIRECTORY module_dirfd = open(".", O_RDONLY | O_DIRECTORY | O_CLOEXEC); #endif + /* Update the VFS snapshot with the now-pinned root dirfd. */ + vfs_set_module_root(module_dir, module_dirlen, module_dirfd); if (module_dirlen) sanitize_paths = 1; diff --git a/vfs/dirstack.c b/vfs/dirstack.c index e716e6ec2..75508f321 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -55,12 +55,12 @@ int path_has_dotdot_component(const char *path) int abspath_excluded_by_module(const char *abspath, int name_is_dir) { (void)name_is_dir; - if (!am_daemon || !abspath || !module_dir) + if (!am_daemon || !abspath || !vfs.module_dir) return 0; - if (module_dirlen <= 1) /* module root is "/": nothing is outside */ + if (vfs.module_dirlen <= 1) /* module root is "/": nothing is outside */ return 0; - if (strncmp(abspath, module_dir, module_dirlen) == 0 - && (abspath[module_dirlen] == '\0' || abspath[module_dirlen] == '/')) + if (strncmp(abspath, vfs.module_dir, vfs.module_dirlen) == 0 + && (abspath[vfs.module_dirlen] == '\0' || abspath[vfs.module_dirlen] == '/')) return 0; /* inside the module: name-based exclude is not a boundary */ /* Not under the module root. An ABSOLUTE walk passes through the module * root's ancestors ("/", "/home", ...) on the way down -- those are not @@ -72,7 +72,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) * before we get here. */ size_t alen = strlen(abspath); if (alen == 0 - || (strncmp(abspath, module_dir, alen) == 0 && module_dir[alen] == '/')) + || (strncmp(abspath, vfs.module_dir, alen) == 0 && vfs.module_dir[alen] == '/')) return 0; /* ancestor of the module root: still descending */ return vfs.operator_path_resolve ? 1 : 0; } @@ -80,7 +80,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) /* Open a trusted absolute anchor directory as an owned dirfd. When the anchor is - * the served module root and the daemon pinned it by identity (module_dirfd), dup + * the served module root and the daemon pinned it by identity (vfs.module_dirfd), dup * that fd rather than re-resolving the absolute path with openat(AT_FDCWD, ...) -- * which re-traverses the module's ancestors as the dropped-privilege module uid * and EACCESes when the module sits under a non-traversable parent (a 0700 home). @@ -88,8 +88,8 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) * callers (the secure resolver and dpc_dir_fd both require these three). */ int open_anchor_dirfd(const char *path) { - if (module_dirfd >= 0 && am_daemon && module_dir && strcmp(path, module_dir) == 0) - return dup(module_dirfd); + if (vfs.module_dirfd >= 0 && am_daemon && vfs.module_dir && strcmp(path, vfs.module_dir) == 0) + return dup(vfs.module_dirfd); return openat(AT_FDCWD, path, O_RDONLY | O_DIRECTORY); } diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index a99f94b47..a4a9fd956 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -94,8 +94,8 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz * absolute symlink target) restarts at "/". */ char abspath[MAXPATHLEN]; abspath[0] = '\0'; - if (am_daemon && module_dir && module_dir[0] == '/') - strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */ + if (am_daemon && vfs.module_dir && vfs.module_dir[0] == '/') + strlcpy(abspath, vfs.module_dir, sizeof abspath); /* "/" for a path=/ module */ /* Path-walk state. `remaining` is the unconsumed tail; we splice * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a diff --git a/vfs/secure_open.c b/vfs/secure_open.c index d3fa8af81..1f13507b7 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -35,7 +35,7 @@ extern int open_noatime; * chroot confines the outer path, not the inner module. */ int vfs_relpath_active(void) { - if (am_daemon && am_chrooted && module_dirlen) + if (am_daemon && am_chrooted && vfs.module_dirlen) return 1; return !am_chrooted && (am_daemon || !am_sender); } @@ -186,8 +186,6 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode) { extern int am_daemon, am_chrooted; - extern char *module_dir; - extern unsigned int module_dirlen; char modrel_buf[MAXPATHLEN]; int reanchored = 0; @@ -198,30 +196,30 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t } /* Sanitizing daemon (am_daemon && !am_chrooted) and the /./ inner-module - * chroot (am_daemon && am_chrooted && module_dirlen) -- both keep the module + * chroot (am_daemon && am_chrooted && vfs.module_dirlen) -- both keep the module * root, not the cwd, as the trust boundary. Here we have chdir'd into a * sub-dir of the module (the transfer destination), so a relative alt-dest * like "../01" may legitimately climb to a sibling that is still inside the * module (#915). Confining beneath the cwd would reject that climb. * Re-anchor at the module root by prefixing the cwd's module-relative path * (from rsync's logical vfs.curr_dir[], a guaranteed lexical prefix of - * module_dir, unlike getcwd()) and resolving beneath module_dir; RESOLVE_ + * vfs.module_dir, unlike getcwd()) and resolving beneath vfs.module_dir; RESOLVE_ * BENEATH then allows in-module climbs and still rejects escapes. Only for - * paths that contain "..". module_dirlen is 0 for a `path = /` module - * (clientserver.c), so the non-chroot arm gates on module_dir, not its + * paths that contain "..". vfs.module_dirlen is 0 for a `path = /` module + * (clientserver.c), so the non-chroot arm gates on vfs.module_dir, not its * length, to cover that case too -- the prefix check below treats - * module_dirlen 0 as "module root is /". */ - if (am_daemon && (!am_chrooted || module_dirlen) - && module_dir && module_dir[0] == '/' + * vfs.module_dirlen 0 as "module root is /". */ + if (am_daemon && (!am_chrooted || vfs.module_dirlen) + && vfs.module_dir && vfs.module_dir[0] == '/' && (basedir == NULL || basedir[0] != '/') && (path_has_dotdot_component(relpath) || (basedir && path_has_dotdot_component(basedir)))) { const char *p; int n; - if (vfs.curr_dir_len >= module_dirlen - && strncmp(vfs.curr_dir, module_dir, module_dirlen) == 0 - && (vfs.curr_dir[module_dirlen] == '\0' || vfs.curr_dir[module_dirlen] == '/')) { - for (p = vfs.curr_dir + module_dirlen; *p == '/'; p++) {} + if (vfs.curr_dir_len >= vfs.module_dirlen + && strncmp(vfs.curr_dir, vfs.module_dir, vfs.module_dirlen) == 0 + && (vfs.curr_dir[vfs.module_dirlen] == '\0' || vfs.curr_dir[vfs.module_dirlen] == '/')) { + for (p = vfs.curr_dir + vfs.module_dirlen; *p == '/'; p++) {} if (basedir) n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", p, *p ? "/" : "", basedir, relpath); @@ -232,7 +230,7 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t errno = ENAMETOOLONG; return -1; } - basedir = module_dir; /* absolute, operator-trusted anchor */ + basedir = vfs.module_dir; /* absolute, operator-trusted anchor */ relpath = modrel_buf; reanchored = 1; } diff --git a/vfs/vfs.c b/vfs/vfs.c index d94698df6..a75ee0f2d 100644 --- a/vfs/vfs.c +++ b/vfs/vfs.c @@ -21,14 +21,26 @@ struct vfs vfs = { .module_dirfd = -1, }; -/* Reset the VFS between transfers. Idempotent; re-establishes the same safe - * sentinels as the static initializer. Behaviorally inert until the held- - * dirfd cache and module snapshot are migrated into struct vfs in later - * commits (the live cache is still the dpc_* statics in vfs/dircache.c). */ +/* Reset the VFS to a safe state. Idempotent; re-establishes the same safe + * sentinels as the static initializer, and clears the module-root snapshot so + * confinement state can never be stale (no module is attached yet). */ void vfs_init(void) { vfs.dpc.base = -1; vfs.dpc.anchor = (const char *)-2; vfs.dpc.depth = 0; + vfs.module_dir = NULL; + vfs.module_dirlen = 0; vfs.module_dirfd = -1; } + +/* Snapshot the served daemon module root for the confinement checks. Called + * by clientserver.c once the module path is final (with dirfd == -1), and again + * once the root dirfd is pinned. The dirfd is BORROWED -- open_anchor_dirfd() + * dup()s it; the VFS never closes it. */ +void vfs_set_module_root(const char *dir, unsigned int len, int dirfd) +{ + vfs.module_dir = dir; + vfs.module_dirlen = len; + vfs.module_dirfd = dirfd; +} diff --git a/vfs/vfs.h b/vfs/vfs.h index fcf13b825..5e452cebe 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -6,7 +6,7 @@ * operator-path ownership walk and the daemon module confinement) so the * mainline protocol/transfer code can stay clean. The state lives in the * single global "struct vfs vfs" below; the implementations are in the - * per-concern vfs/*.c files (one per operation family, plus the resolver, + * per-concern sources under vfs/ (one per operation family, plus the resolver, * dirstack, dircache and owner-walk cores). * * Most operations come in up to three forms, sharing one leaf behaviour: @@ -69,6 +69,10 @@ extern struct vfs vfs; * from the static initializer in vfs/vfs.c, not from this call. */ void vfs_init(void); +/* Snapshot the served daemon module root (clientserver.c calls this); the + * dirfd is borrowed, never closed by the VFS. */ +void vfs_set_module_root(const char *dir, unsigned int len, int dirfd); + /* Race-safe path resolution (vfs/secure_open.c). */ int vfs_relpath_active(void); int vfs_symlink_optout_allowed(void); diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index 6e4cb161d..bf707f235 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -33,9 +33,6 @@ extern int copy_links; extern int copy_unsafe_links; extern int insecure_links; extern int module_id; -extern char *module_dir; -extern unsigned int module_dirlen; -extern int module_dirfd; /* Dry-run / read-only guard macros shared by the syscall wrappers. */ #define RETURN_ERROR_IF(x,e) \ From 230d25b32b8987266346ae60813f4596be92111e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 20:52:22 +1000 Subject: [PATCH 26/69] vfs: thread the operator context into abspath_excluded_by_module First step of the API redesign. Define the per-call VFS_* flags (VFS_ALLOW_SYMLINK / VFS_OPERATOR_PATH / VFS_REMOVEDIR) and stop abspath_excluded_by_module() from reading the vfs.operator_path_resolve global directly -- it now takes an explicit is_operator argument. The strict resolver stays confined beneath its anchor (always within the module), so its two call sites pass is_operator=0 (the check never fires there anyway). The ownership walk is reached in BOTH operator context (--backup-dir/--temp-dir/--partial-dir) and non-operator context (daemon log-file/motd/config opens, which may legitimately live outside the module), so vfs_open_owner_walk()/vfs_owner_walk_parent() capture the global and pass it down through ona_open(). No behavior change -- the value still comes from the global; later commits replace that source with the explicit flag and delete the global. --- vfs/dirstack.c | 12 +++++++----- vfs/owner_walk.c | 19 ++++++++++--------- vfs/secure_open.c | 2 +- vfs/vfs.h | 7 +++++++ vfs/vfs_internal.h | 2 +- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/vfs/dirstack.c b/vfs/dirstack.c index 75508f321..ee2e1d4ef 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -43,7 +43,7 @@ int path_has_dotdot_component(const char *path) /* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the * serving module's root, for an operator/peer-supplied path that must stay in the - * module (--partial-dir/--backup-dir/alt-basis: vfs.operator_path_resolve). An + * module (--partial-dir/--backup-dir/alt-basis: is_operator). An * in-tree symlink owned by uid 0 / the euid is followed by design, so it can * redirect the resolved target outside the module; this catches that escape. * @@ -52,7 +52,7 @@ int path_has_dotdot_component(const char *path) * name is not excluded may still resolve into an excluded IN-module subtree, * exactly as in stock rsync. The defense for a writable module is `munge * symlinks` (see rsyncd.conf(5)), not this walk. No-op unless we're a daemon. */ -int abspath_excluded_by_module(const char *abspath, int name_is_dir) +int abspath_excluded_by_module(const char *abspath, int name_is_dir, int is_operator) { (void)name_is_dir; if (!am_daemon || !abspath || !vfs.module_dir) @@ -66,7 +66,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) * root's ancestors ("/", "/home", ...) on the way down -- those are not * "outside", just not-yet-arrived, so allow them. A path that has truly * DIVERGED from the module tree is outside: refuse it for an operator/peer - * path that must stay in the module (vfs.operator_path_resolve); other daemon + * path that must stay in the module (is_operator); other daemon * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. * The --insecure-links / "insecure links = yes" opt-out short-circuits * before we get here. */ @@ -74,7 +74,7 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir) if (alen == 0 || (strncmp(abspath, vfs.module_dir, alen) == 0 && vfs.module_dir[alen] == '/')) return 0; /* ancestor of the module root: still descending */ - return vfs.operator_path_resolve ? 1 : 0; + return is_operator ? 1 : 0; } #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) @@ -202,7 +202,9 @@ int ds_descend(struct dirstack *ds, const char *part, int *hops) return -1; /* exclude-aware: refuse descending into a module-hidden dir (catches a * symlink that redirected the walk into an excluded subtree). */ - if (abspath_excluded_by_module(ds->abspath, 1)) { + /* The strict resolver stays confined beneath the anchor (within the + * module), so this never actually refuses; pass is_operator=0. */ + if (abspath_excluded_by_module(ds->abspath, 1, 0)) { errno = ELOOP; return -1; } diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index a4a9fd956..5d5186ec3 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -66,7 +66,7 @@ static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp /* Core walk. When out_abs is non-NULL and the path resolves to a directory * (O_DIRECTORY), the resolved absolute path is copied there -- vfs_owner_walk_parent * uses it to filter-check the (otherwise unchecked) leaf basename. */ -static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap) +static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, size_t out_cap, int is_operator) { #if defined AT_FDCWD && defined O_NOFOLLOW /* O_CLOEXEC predates some still-supported targets; mirror rand_bytes()'s @@ -149,7 +149,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, 0)) { + if (abspath_excluded_by_module(abspath, 0, is_operator)) { saved_errno = ELOOP; goto out; } @@ -219,7 +219,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, S_ISDIR(lst.st_mode))) { + if (abspath_excluded_by_module(abspath, S_ISDIR(lst.st_mode), is_operator)) { saved_errno = ELOOP; goto out; } @@ -243,7 +243,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, 1)) { + if (abspath_excluded_by_module(abspath, 1, is_operator)) { saved_errno = ELOOP; goto out; } @@ -294,7 +294,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz int vfs_open_owner_walk(const char *path, int flags, mode_t mode) { - return ona_open(path, flags, mode, NULL, 0); + return ona_open(path, flags, mode, NULL, 0, vfs.operator_path_resolve); } /* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied @@ -317,11 +317,12 @@ int vfs_owner_walk_parent(const char *path, const char **bname) char dir[MAXPATHLEN], pabs[MAXPATHLEN]; size_t dlen; int dfd; + int is_operator = vfs.operator_path_resolve; *bname = slash ? slash + 1 : path; pabs[0] = '\0'; if (!slash) - dfd = ona_open(".", O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); + dfd = ona_open(".", O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs, is_operator); else { dlen = slash == path ? 1 : (size_t)(slash - path); /* "/x" -> parent "/" */ if (dlen >= sizeof dir) { @@ -330,7 +331,7 @@ int vfs_owner_walk_parent(const char *path, const char **bname) } memcpy(dir, path, dlen); dir[dlen] = '\0'; - dfd = ona_open(dir, O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs); + dfd = ona_open(dir, O_RDONLY | O_DIRECTORY, 0, pabs, sizeof pabs, is_operator); } if (dfd < 0) return -1; @@ -353,8 +354,8 @@ int vfs_owner_walk_parent(const char *path, const char **bname) } /* For an absent leaf the op may create a dir, so also test dir-only * filter rules (a "/foo/" rule never matches a file). */ - refuse = abspath_excluded_by_module(leafabs, isdir) - || (absent && abspath_excluded_by_module(leafabs, 1)); + refuse = abspath_excluded_by_module(leafabs, isdir, is_operator) + || (absent && abspath_excluded_by_module(leafabs, 1, is_operator)); if (refuse) { close(dfd); errno = ELOOP; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index 1f13507b7..962e22222 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -130,7 +130,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) < (int)sizeof leafabs - && abspath_excluded_by_module(leafabs, 0)) { + && abspath_excluded_by_module(leafabs, 0, 0)) { errno = ELOOP; goto cleanup; } diff --git a/vfs/vfs.h b/vfs/vfs.h index 5e452cebe..8d6bc6b76 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -37,6 +37,13 @@ /* Max held ancestor-dirfd cache depth (was DPC_MAXDEPTH in syscall.c). */ #define VFS_DPC_MAXDEPTH 64 +/* Per-call flags for the unified vfs_* operations. The default (0) is the + * secure, no-follow parent resolve used by the receiver; the flags are explicit + * opt-ins that each carry a security meaning the call site is asserting. */ +#define VFS_ALLOW_SYMLINK (1<<0) /* call site is known-safe to follow symlinks */ +#define VFS_OPERATOR_PATH (1<<1) /* operator-supplied path: ownership walk + module confinement */ +#define VFS_REMOVEDIR (1<<2) /* unlink op targets a directory (AT_REMOVEDIR) */ + /* The single global VFS state instance (defined in vfs/vfs.c). * * Only curr_dir/curr_dir_len/operator_path_resolve are read by mainline code; diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index bf707f235..78c9a292b 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -52,7 +52,7 @@ extern int module_id; /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); -int abspath_excluded_by_module(const char *abspath, int name_is_dir); +int abspath_excluded_by_module(const char *abspath, int name_is_dir, int is_operator); #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) From e34177b12442278eb1b1676b40b4970d37ee6738 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 21:14:09 +1000 Subject: [PATCH 27/69] vfs: unify the mkdir family into vfs_mkdir(dirfd, path, mode, flags) Collapse vfs_mkdir / vfs_mkdir_at / vfs_mkdir_atfd into one call. dirfd == VFS_AT_FDCWD resolves the path argument; a real held dirfd makes path a single component created directly under it (the held-fd form validates that path is a lone harmless component -- rejects empty, any '/', "." and ".." -- so it cannot reintroduce path resolution under the pinned dir). The plain-vs-secure choice is now an explicit per-call flag instead of a function-name choice plus the vfs.operator_path_resolve global: VFS_ALLOW_SYMLINK the call site asserts it is safe to follow symlinks (the old plain vfs_mkdir); checked first, wins over VFS_OPERATOR_PATH (no caller passes both) VFS_OPERATOR_PATH operator-supplied path: ownership walk + module confinement (the old global) default 0 secure receiver resolve (the old vfs_mkdir_at) VFS_AT_FDCWD is a VFS-owned sentinel (maps to AT_FDCWD where available, else a value that routes the held-fd form to ENOSYS) so mainline code never has to mention AT_FDCWD directly -- avoids a compile break on platforms lacking it. mkdir stops reading vfs.operator_path_resolve. vfs_owner_walk_parent now takes the operator context as an is_operator parameter; its non-mkdir callers (the other _at wrappers, generator hard-link) still pass the global verbatim and convert in later steps. make_path() gains MKP_OPERATOR (translated to VFS_OPERATOR_PATH) so the backup-dir creation stays an operator path by path-semantics, not by relying on the global being set. Call sites classified: dest dir (main.c) follows symlinks (user's local dest); transfer dirs (generator, make_path transfer callers) secure; backup-dir (backup.c, get_backup_name->make_path) and partial-dir (handle_partial_dir) operator. The operator_path_resolve=1 blocks stay for now since they still wrap non-mkdir ops; the global value and the explicit flag agree at every converted mkdir site. Full suite 190/50; backup/partial/daemon/exclude/filter/symlink-race subset green. --- backup.c | 8 ++-- generator.c | 8 ++-- main.c | 2 +- rsync.h | 1 + util1.c | 7 +-- vfs/link.c | 4 +- vfs/mkdir.c | 112 +++++++++++++++++++++++------------------------ vfs/mknod.c | 2 +- vfs/open.c | 2 +- vfs/owner_walk.c | 3 +- vfs/rename.c | 4 +- vfs/stat.c | 2 +- vfs/unlink.c | 2 +- vfs/vfs.h | 24 +++++++--- 14 files changed, 98 insertions(+), 83 deletions(-) diff --git a/backup.c b/backup.c index 8fcec199c..811748ec7 100644 --- a/backup.c +++ b/backup.c @@ -124,7 +124,7 @@ static BOOL copy_valid_path(const char *fname) for ( ; b; name = b + 1, b = strchr(name, '/')) { *b = '\0'; - while (vfs_mkdir_at(backup_dir_buf, ACCESSPERMS) < 0) { + while (vfs_mkdir(VFS_AT_FDCWD, backup_dir_buf, ACCESSPERMS, VFS_OPERATOR_PATH) < 0) { if (errno == EEXIST) { val = validate_backup_dir(); if (val > 0) @@ -201,8 +201,10 @@ char *get_backup_name(const char *fname) return NULL; } if (backup_dir_len > 1) - dirbuf[backup_dir_len-1] = '\0'; - ret = make_path(dirbuf, 0); + backup_dir_buf[backup_dir_len-1] = '\0'; + ret = make_path(backup_dir_buf, MKP_OPERATOR); + if (backup_dir_len > 1) + backup_dir_buf[backup_dir_len-1] = '/'; if (ret < 0) return NULL; initialized = 1; diff --git a/generator.c b/generator.c index 93d4e693e..cb6fc9568 100644 --- a/generator.c +++ b/generator.c @@ -977,7 +977,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) * the plain path (a lower-severity, non-root basis lookup). */ if (!am_daemon && am_root >= 0 && !vfs_symlink_optout_allowed()) { const char *leaf; - int dfd = vfs_owner_walk_parent(path, &leaf); + int dfd = vfs_owner_walk_parent(path, &leaf, vfs.operator_path_resolve); int r, e; if (dfd < 0) return -1; @@ -1385,10 +1385,10 @@ static int gen_entry_mkdir(char *fname, struct file_struct *file, mode_t mode) { int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { - const char *slash = strrchr(fname, '/'); - return vfs_mkdir_atfd(dfd, slash ? slash + 1 : fname, mode); + char *slash = strrchr(fname, '/'); + return vfs_mkdir(dfd, slash ? slash + 1 : fname, mode, 0); } - return vfs_mkdir_at(fname, mode); + return vfs_mkdir(VFS_AT_FDCWD, fname, mode, 0); } static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t mode) diff --git a/main.c b/main.c index aa2eef7ed..bec381c06 100644 --- a/main.c +++ b/main.c @@ -799,7 +799,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) exit_cleanup(RERR_SYNTAX); } - if (vfs_mkdir(dest_path, ACCESSPERMS) != 0) { + if (vfs_mkdir(VFS_AT_FDCWD, dest_path, ACCESSPERMS, VFS_ALLOW_SYMLINK) != 0) { mkdir_error: rsyserr(FERROR, errno, "mkdir %s failed", full_fname(dest_path)); diff --git a/rsync.h b/rsync.h index 8fefdaff7..13bf5154e 100644 --- a/rsync.h +++ b/rsync.h @@ -337,6 +337,7 @@ enum delret { /* Defines for make_path() */ #define MKP_DROP_NAME (1<<0) /* drop trailing filename or trailing slash */ #define MKP_SKIP_SLASH (1<<1) /* skip one or more leading slashes */ +#define MKP_OPERATOR (1<<2) /* path is operator-supplied (-> VFS_OPERATOR_PATH) */ /* Defines for maybe_send_keepalive() */ #define MSK_ALLOW_FLUSH (1<<0) diff --git a/util1.c b/util1.c index 4ca416cf3..a036aafaf 100644 --- a/util1.c +++ b/util1.c @@ -205,6 +205,7 @@ int make_path(char *fname, int flags) { char *end, *p; int ret = 0; + int vfs_flags = (flags & MKP_OPERATOR) ? VFS_OPERATOR_PATH : 0; if (flags & MKP_SKIP_SLASH) { while (*fname == '/') @@ -232,7 +233,7 @@ int make_path(char *fname, int flags) else errno = ENOTDIR; } - } else if (vfs_mkdir_at(fname, ACCESSPERMS) == 0) { + } else if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) == 0) { ret++; break; } @@ -271,7 +272,7 @@ int make_path(char *fname, int flags) p += strlen(p); if (ret < 0) /* Skip mkdir on error, but keep restoring the path. */ continue; - if (vfs_mkdir_at(fname, ACCESSPERMS) < 0) + if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) < 0) ret = -ret - 1; else ret++; @@ -1500,7 +1501,7 @@ int handle_partial_dir(const char *fname, int create) } statret = -1; } - if (statret < 0 && vfs_mkdir_at(dir, 0700) < 0) { + if (statret < 0 && vfs_mkdir(VFS_AT_FDCWD, dir, 0700, VFS_OPERATOR_PATH) < 0) { vfs.operator_path_resolve = 0; *fn = '/'; return 0; diff --git a/vfs/link.c b/vfs/link.c index a93806bf9..3b8d0c9eb 100644 --- a/vfs/link.c +++ b/vfs/link.c @@ -70,10 +70,10 @@ int vfs_link_at(const char *old_path, const char *new_path) if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return vfs_link(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, vfs.operator_path_resolve); if (old_dfd < 0) return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, vfs.operator_path_resolve); if (new_dfd < 0) { e = errno; close(old_dfd); diff --git a/vfs/mkdir.c b/vfs/mkdir.c index 1cf2e4986..2d655cfcd 100644 --- a/vfs/mkdir.c +++ b/vfs/mkdir.c @@ -56,47 +56,26 @@ void trim_trailing_slashes(char *name) } } -int vfs_mkdir(char *path, mode_t mode) +/* Secure receiver-side resolve for a path mkdir. An operator path + * (--backup-dir/--temp-dir, may live outside the tree) uses the ownership walk; + * otherwise the strict transfer-path resolver (refuse all symlinks, confine + * beneath the transfer root). mkdir() resolves parent symlinks at every + * component, so a parent-component swap can place an attacker-named directory + * outside the module -- defence is to resolve the parent securely and mkdirat() + * the leaf. Falls through to a plain mkdir() in non-daemon/sender, chrooted, + * no-parent and absolute-path cases. */ +static int vfs__mkdir_secure(char *path, mode_t mode, int flags) { - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - trim_trailing_slashes(path); - return mkdir(path, mode); -} - -/* - Symlink-race-safe variant of vfs_mkdir() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model and design rationale. - - mkdir() resolves parent symlinks at every component, so a parent- - component swap can place an attacker-named directory outside the - module. Defence: open the parent of fname under vfs_resolve_open() - and call mkdirat() against that dirfd. - - Mutates path in place to trim trailing slashes (matches vfs_mkdir()). - Falls through to vfs_mkdir() in dry-run, non-daemon, chrooted, no- - parent and absolute-path cases. -*/ -int vfs_mkdir_at(char *path, mode_t mode) -{ -#ifdef AT_FDCWD +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; + const char *bname, *slash; int dfd, ret, e; size_t dlen; - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - trim_trailing_slashes(path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { + if (flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) return mkdir(path, mode); - dfd = vfs_owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname, 1); if (dfd < 0) return -1; ret = mkdirat(dfd, bname, mode); @@ -105,18 +84,14 @@ int vfs_mkdir_at(char *path, mode_t mode) errno = e; return ret; } -#endif if (!vfs_relpath_active()) return mkdir(path, mode); - - if (!path || !*path || *path == '/') + if (!*path || *path == '/') return mkdir(path, mode); - slash = strrchr(path, '/'); if (!slash) return mkdir(path, mode); - dlen = slash - path; if (dlen >= sizeof dirpath) { errno = ENAMETOOLONG; @@ -129,15 +104,54 @@ int vfs_mkdir_at(char *path, mode_t mode) dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; - ret = mkdirat(dfd, bname, mode); e = errno; close(dfd); errno = e; return ret; #else - return vfs_mkdir(path, mode); + (void)flags; + return mkdir(path, mode); +#endif +} + +/* Unified mkdir. dirfd == VFS_AT_FDCWD resolves `path`; a real held dirfd makes + * `path` a single component created directly under it. flags: VFS_ALLOW_SYMLINK + * (trusted, follow symlinks), VFS_OPERATOR_PATH (operator-supplied path), + * default 0 (secure receiver resolve). */ +int vfs_mkdir(int dirfd, char *path, mode_t mode, int flags) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + RETURN_ERROR_IF_NULL(path); + + if (dirfd != VFS_AT_FDCWD) { +#ifdef AT_FDCWD + /* Held-fd: `path` must be a single harmless component -- reject + * anything that could reintroduce path resolution under the pinned + * dir (empty, any '/', "." or ".."). */ + if (!*path || strchr(path, '/') + || (path[0] == '.' && (path[1] == '\0' + || (path[1] == '.' && path[2] == '\0')))) { + errno = EINVAL; + return -1; + } + return mkdirat(dirfd, path, mode); +#else + (void)dirfd; (void)mode; + errno = ENOSYS; + return -1; #endif + } + + trim_trailing_slashes(path); + + /* VFS_ALLOW_SYMLINK takes precedence: the call site asserts the path is + * trusted, so follow symlinks (the legacy plain mkdir). */ + if (flags & VFS_ALLOW_SYMLINK) + return mkdir(path, mode); + + return vfs__mkdir_secure(path, mode, flags); } /* like mkstemp but forces permissions */ @@ -302,19 +316,3 @@ int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path) return fd; #endif } - - - - -int vfs_mkdir_atfd(int dfd, const char *name, mode_t mode) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return mkdirat(dfd, name, mode); -#else - (void)dfd; (void)name; (void)mode; - errno = ENOSYS; - return -1; -#endif -} diff --git a/vfs/mknod.c b/vfs/mknod.c index 38dc78f0f..64344045c 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -122,7 +122,7 @@ int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return vfs_mknod(pathname, mode, dev); - dfd = vfs_owner_walk_parent(pathname, &bname); + dfd = vfs_owner_walk_parent(pathname, &bname, vfs.operator_path_resolve); if (dfd < 0) return -1; ret = mknodat(dfd, bname, mode, dev); diff --git a/vfs/open.c b/vfs/open.c index b4fbfe7c1..f20b32681 100644 --- a/vfs/open.c +++ b/vfs/open.c @@ -67,7 +67,7 @@ int vfs_open_at(const char *pathname, int flags, mode_t mode) if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return vfs_open(pathname, flags, mode); - dfd = vfs_owner_walk_parent(pathname, &bname); + dfd = vfs_owner_walk_parent(pathname, &bname, vfs.operator_path_resolve); if (dfd < 0) return -1; ret = openat(dfd, bname, flags | O_NOFOLLOW, mode); diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 5d5186ec3..9aa2004f8 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -311,13 +311,12 @@ int vfs_open_owner_walk(const char *path, int flags, mode_t mode) /* For an operator-supplied path: open its parent directory via the ownership * walk (handles absolute and relative paths) and point *bname at the final * component. Returns the dirfd (caller closes) or -1 with errno set. */ -int vfs_owner_walk_parent(const char *path, const char **bname) +int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) { const char *slash = strrchr(path, '/'); char dir[MAXPATHLEN], pabs[MAXPATHLEN]; size_t dlen; int dfd; - int is_operator = vfs.operator_path_resolve; *bname = slash ? slash + 1 : path; pabs[0] = '\0'; diff --git a/vfs/rename.c b/vfs/rename.c index ee97e4085..a6b519321 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -66,10 +66,10 @@ int vfs_rename_at(const char *old_path, const char *new_path) if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return vfs_rename(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, vfs.operator_path_resolve); if (old_dfd < 0) return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, vfs.operator_path_resolve); if (new_dfd < 0) { e = errno; close(old_dfd); diff --git a/vfs/stat.c b/vfs/stat.c index 7796b82bf..0089080d7 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -66,7 +66,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return fallback(path, st); - dfd = vfs_owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname, vfs.operator_path_resolve); if (dfd < 0) return -1; ret = fstatat(dfd, bname, st, at_flags); diff --git a/vfs/unlink.c b/vfs/unlink.c index b8e12eb77..96af614ab 100644 --- a/vfs/unlink.c +++ b/vfs/unlink.c @@ -51,7 +51,7 @@ int vfs_unlink_at(const char *path) if (vfs.operator_path_resolve) { if (vfs_symlink_optout_allowed()) return unlink(path); - dfd = vfs_owner_walk_parent(path, &bname); + dfd = vfs_owner_walk_parent(path, &bname, vfs.operator_path_resolve); if (dfd < 0) return -1; ret = unlinkat(dfd, bname, 0); diff --git a/vfs/vfs.h b/vfs/vfs.h index 8d6bc6b76..4208e0bea 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -37,9 +37,22 @@ /* Max held ancestor-dirfd cache depth (was DPC_MAXDEPTH in syscall.c). */ #define VFS_DPC_MAXDEPTH 64 +/* dirfd sentinel for the unified vfs_* ops: "no held directory -- resolve the + * path argument". Always defined (even on platforms without AT_FDCWD, where the + * held-fd forms are unavailable and report ENOSYS), so mainline code never has + * to mention AT_FDCWD directly. */ +#ifdef AT_FDCWD +#define VFS_AT_FDCWD AT_FDCWD +#else +#define VFS_AT_FDCWD (-100) +#endif + /* Per-call flags for the unified vfs_* operations. The default (0) is the * secure, no-follow parent resolve used by the receiver; the flags are explicit - * opt-ins that each carry a security meaning the call site is asserting. */ + * opt-ins that each carry a security meaning the call site is asserting. + * VFS_ALLOW_SYMLINK and VFS_OPERATOR_PATH are mutually exclusive policies; if + * both are passed VFS_ALLOW_SYMLINK wins (it is checked first). No call site + * needs both. */ #define VFS_ALLOW_SYMLINK (1<<0) /* call site is known-safe to follow symlinks */ #define VFS_OPERATOR_PATH (1<<1) /* operator-supplied path: ownership walk + module confinement */ #define VFS_REMOVEDIR (1<<2) /* unlink op targets a directory (AT_REMOVEDIR) */ @@ -88,7 +101,7 @@ int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mo /* Operator-supplied-path resolution by ownership (vfs/owner_walk.c). */ int vfs_open_owner_walk(const char *path, int flags, mode_t mode); -int vfs_owner_walk_parent(const char *path, const char **bname); +int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator); /* Held ancestor-dirfd cache for directory traversal (vfs/dircache.c). */ int vfs_opendir(const char *dirname); @@ -147,9 +160,10 @@ int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *ne /* mkdir / mkstemp and the trim_trailing_slashes path helper (vfs/mkdir.c). */ void trim_trailing_slashes(char *name); -int vfs_mkdir(char *path, mode_t mode); -int vfs_mkdir_at(char *path, mode_t mode); -int vfs_mkdir_atfd(int dfd, const char *name, mode_t mode); +/* dirfd == VFS_AT_FDCWD: resolve `path` (secure parent walk unless + * VFS_ALLOW_SYMLINK / VFS_OPERATOR_PATH); a real held dirfd: `path` is a single + * component created directly under it. */ +int vfs_mkdir(int dirfd, char *path, mode_t mode, int flags); int vfs_mkstemp(char *template, mode_t perms); int vfs_mkstemp_atfd(int dfd, char *filename, mode_t perms); int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path); From 31c3b8f3d0e2723b895a70bb27dfd6eda44e804c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 21:46:54 +1000 Subject: [PATCH 28/69] vfs: unify the mknod family into vfs_mknod(dirfd, path, mode, dev, flags) Collapse vfs_mknod / vfs_mknod_at / vfs_mknod_atfd into one call, mirroring vfs_mkdir. dirfd == VFS_AT_FDCWD resolves the path (secure receiver resolve by default, ownership walk under VFS_OPERATOR_PATH, plain follow under VFS_ALLOW_SYMLINK); a real held dirfd makes path a single validated component. The old plain/secure/atfd bodies become static helpers (vfs__mknod_plain/_secure/_atfd) behind the public dispatcher; the secure body reads the operator context from VFS_OPERATOR_PATH instead of the vfs.operator_path_resolve global. Call sites: backup file node (make_backup_inner, operator) -> VFS_OPERATOR_PATH; transfer device/fifo (generator) -> secure / held-fd. The t_symlink_secure harness's plain PoC call becomes VFS_ALLOW_SYMLINK (the vulnerable follow it demonstrates) and its secure calls flags=0. Full suite 190/50. --- backup.c | 2 +- generator.c | 4 ++-- t_symlink_secure.c | 10 +++++----- vfs/mknod.c | 43 ++++++++++++++++++++++++++++++++----------- vfs/vfs.h | 4 +--- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/backup.c b/backup.c index 811748ec7..4c211a3fe 100644 --- a/backup.c +++ b/backup.c @@ -358,7 +358,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) /* Check to see if this is a device file, or link */ if ((am_root && preserve_devices && IS_DEVICE(file->mode)) || (preserve_specials && IS_SPECIAL(file->mode))) { - if (vfs_mknod_at(buf, file->mode, sx.st.st_rdev) < 0) + if (vfs_mknod(VFS_AT_FDCWD, buf, file->mode, sx.st.st_rdev, VFS_OPERATOR_PATH) < 0) rsyserr(FERROR, errno, "mknod %s failed", full_fname(buf)); else if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: DEVICE %s successful.\n", fname); diff --git a/generator.c b/generator.c index cb6fc9568..59a977b93 100644 --- a/generator.c +++ b/generator.c @@ -1456,9 +1456,9 @@ static int gen_entry_mknod(const char *path, struct file_struct *file, mode_t mo /* vfs_mknod_atfd can't create a socket (no portable bindat); fall back. */ if (!S_ISSOCK(mode) && (dfd = vfs_cached_dirfd(path, file)) >= 0) { const char *slash = strrchr(path, '/'); - return vfs_mknod_atfd(dfd, slash ? slash + 1 : path, mode, rdev); + return vfs_mknod(dfd, slash ? slash + 1 : path, mode, rdev, 0); } - return vfs_mknod_at(path, mode, rdev); + return vfs_mknod(VFS_AT_FDCWD, path, mode, rdev, 0); } static int gen_entry_unlink(const char *path, struct file_struct *file) diff --git a/t_symlink_secure.c b/t_symlink_secure.c index e8e730070..551b70d66 100644 --- a/t_symlink_secure.c +++ b/t_symlink_secure.c @@ -125,7 +125,7 @@ int main(int argc, char **argv) check_clobbered("poc vfs_symlink bare", "../outside/secret_sym", "VULN_SYM_PAYLOAD"); #endif - vfs_mknod("nodpath", S_IFCHR | 0600, 0); + vfs_mknod(VFS_AT_FDCWD, "nodpath", S_IFCHR | 0600, 0, VFS_ALLOW_SYMLINK); check_clobbered("poc vfs_mknod bare", "../outside/secret_nod", ""); return errs ? 1 : 0; } @@ -141,11 +141,11 @@ int main(int argc, char **argv) check_preserved("vfs_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2"); #endif - vfs_mknod_at("nodpath", S_IFCHR | 0600, 0); - check_preserved("vfs_mknod_at bare", "../outside/secret_nod", "VICTIM_NOD"); + vfs_mknod(VFS_AT_FDCWD, "nodpath", S_IFCHR | 0600, 0, 0); + check_preserved("vfs_mknod bare", "../outside/secret_nod", "VICTIM_NOD"); - vfs_mknod_at("sub/nodpath2", S_IFCHR | 0600, 0); - check_preserved("vfs_mknod_at slashed", "../outside/secret_nod2", "VICTIM_NOD2"); + vfs_mknod(VFS_AT_FDCWD, "sub/nodpath2", S_IFCHR | 0600, 0, 0); + check_preserved("vfs_mknod slashed", "../outside/secret_nod2", "VICTIM_NOD2"); if (errs) fprintf(stderr, "%d failure(s)\n", errs); diff --git a/vfs/mknod.c b/vfs/mknod.c index 64344045c..8f6761d96 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -21,7 +21,7 @@ #include /* for the socket+bind() fallback in vfs_mknod() */ #endif -int vfs_mknod(const char *pathname, mode_t mode, dev_t dev) +static int vfs__mknod_plain(const char *pathname, mode_t mode, dev_t dev) { if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; @@ -103,10 +103,10 @@ int vfs_mknod(const char *pathname, mode_t mode, dev_t dev) other special file; on systems where mknod() can't create sockets the at-variant fails instead of re-resolving an unsafe parent. */ -int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) +static int vfs__mknod_secure(const char *pathname, mode_t mode, dev_t dev, int flags) { /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so - * the at-variant won't build there; fall back to vfs_mknod() (#896). */ + * the at-variant won't build there; fall back to the plain mknod (#896). */ #if defined(AT_FDCWD) && defined(HAVE_MKNODAT) char dirpath[MAXPATHLEN]; const char *bname; @@ -119,10 +119,10 @@ int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) RETURN_ERROR_IF_RO_OR_LO; #if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { + if (flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) - return vfs_mknod(pathname, mode, dev); - dfd = vfs_owner_walk_parent(pathname, &bname, vfs.operator_path_resolve); + return vfs__mknod_plain(pathname, mode, dev); + dfd = vfs_owner_walk_parent(pathname, &bname, 1); if (dfd < 0) return -1; ret = mknodat(dfd, bname, mode, dev); @@ -134,10 +134,10 @@ int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) #endif if (!vfs_relpath_active()) - return vfs_mknod(pathname, mode, dev); + return vfs__mknod_plain(pathname, mode, dev); if (!pathname || !*pathname || *pathname == '/') - return vfs_mknod(pathname, mode, dev); + return vfs__mknod_plain(pathname, mode, dev); /* A path with a slash needs vfs_resolve_open to confine its * parent resolution; a top-level path lives in CWD (AT_FDCWD) with @@ -200,7 +200,7 @@ int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) * vfs_mknod(), but a nested one fails safe rather than * re-resolve a potentially unsafe parent. */ if (dfd == AT_FDCWD) - ret = vfs_mknod(pathname, mode, dev); + ret = vfs__mknod_plain(pathname, mode, dev); else errno = EOPNOTSUPP; } @@ -211,11 +211,11 @@ int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev) errno = e; return ret; #else - return vfs_mknod(pathname, mode, dev); + return vfs__mknod_plain(pathname, mode, dev); #endif } -int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) +static int vfs__mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) { #ifdef AT_FDCWD if (dry_run) return 0; @@ -259,3 +259,24 @@ int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) return -1; #endif } + +/* Unified node creation. dirfd == VFS_AT_FDCWD resolves `path`; a real held + * dirfd makes `path` a single component created directly under it. flags: + * VFS_ALLOW_SYMLINK (trusted, plain mknod), VFS_OPERATOR_PATH (operator path), + * default 0 (secure receiver resolve). */ +int vfs_mknod(int dirfd, const char *path, mode_t mode, dev_t dev, int flags) +{ + if (dirfd != VFS_AT_FDCWD) { + /* Held-fd: `path` must be a single harmless component. */ + if (!path || !*path || strchr(path, '/') + || (path[0] == '.' && (path[1] == '\0' + || (path[1] == '.' && path[2] == '\0')))) { + errno = EINVAL; + return -1; + } + return vfs__mknod_atfd(dirfd, path, mode, dev); + } + if (flags & VFS_ALLOW_SYMLINK) + return vfs__mknod_plain(path, mode, dev); + return vfs__mknod_secure(path, mode, dev, flags); +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 4208e0bea..9c470fca8 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -174,9 +174,7 @@ int vfs_lchown_at(const char *fname, uid_t owner, gid_t group); int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group); /* device/fifo/socket node creation (vfs/mknod.c). */ -int vfs_mknod(const char *pathname, mode_t mode, dev_t dev); -int vfs_mknod_at(const char *pathname, mode_t mode, dev_t dev); -int vfs_mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev); +int vfs_mknod(int dirfd, const char *path, mode_t mode, dev_t dev, int flags); /* timestamp setting + crtimes (vfs/times.c). */ int vfs_setattrlist_times(const char *path, STRUCT_STAT *stp); From 6e305172e56cacf4b3ea705eacf680469eec2ca8 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 21 Jun 2026 21:51:24 +1000 Subject: [PATCH 29/69] vfs: unify the symlink family into vfs_symlink(lnk, dirfd, path, flags) Collapse vfs_symlink / vfs_symlink_at / vfs_symlink_atfd into one call. dirfd == VFS_AT_FDCWD resolves `path` (secure receiver resolve by default, plain follow under VFS_ALLOW_SYMLINK); a real held dirfd makes `path` a single validated component. The old bodies become static helpers behind the public dispatcher. Unlike mkdir/mknod, the symlink secure path has NO ownership-walk branch -- it never read vfs.operator_path_resolve. That pre-existing asymmetry is preserved: VFS_OPERATOR_PATH is accepted but resolves the same as the default secure walk (only the parent dir is confined; the link target is stored verbatim and never resolved at creation). The backup-symlink site passes flags=0, which exactly reproduces the old vfs_symlink_at behavior. Full suite 190/50. --- backup.c | 4 +++- generator.c | 4 ++-- t_symlink_secure.c | 10 +++++----- vfs/symlink.c | 35 ++++++++++++++++++++++++++++++----- vfs/vfs.h | 4 +--- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/backup.c b/backup.c index 4c211a3fe..0106bc67b 100644 --- a/backup.c +++ b/backup.c @@ -375,7 +375,9 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) } ret = 2; } else { - if (vfs_symlink_at(sl, buf) < 0) + /* symlink has no ownership-walk branch (see vfs/symlink.c), so + * flags=0 reproduces the old vfs_symlink_at behavior here. */ + if (vfs_symlink(sl, VFS_AT_FDCWD, buf, 0) < 0) rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl); else if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname); diff --git a/generator.c b/generator.c index 59a977b93..9f0053139 100644 --- a/generator.c +++ b/generator.c @@ -1417,9 +1417,9 @@ static int gen_entry_symlink(const char *slnk, const char *path, struct file_str int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); - return vfs_symlink_atfd(slnk, dfd, slash ? slash + 1 : path); + return vfs_symlink(slnk, dfd, slash ? slash + 1 : path, 0); } - return vfs_symlink_at(slnk, path); + return vfs_symlink(slnk, VFS_AT_FDCWD, path, 0); } /* True when this build compiled no fd-relative primitive able to create this diff --git a/t_symlink_secure.c b/t_symlink_secure.c index 551b70d66..ea348cfe6 100644 --- a/t_symlink_secure.c +++ b/t_symlink_secure.c @@ -121,7 +121,7 @@ int main(int argc, char **argv) /* Pre-fix fallback: a no-slash path went to vfs_symlink()/vfs_mknod(), * which open() the basename without O_NOFOLLOW. */ #ifdef TEST_SYMLINK_PLACEHOLDER - vfs_symlink("VULN_SYM_PAYLOAD", "sympath"); + vfs_symlink("VULN_SYM_PAYLOAD", VFS_AT_FDCWD, "sympath", VFS_ALLOW_SYMLINK); check_clobbered("poc vfs_symlink bare", "../outside/secret_sym", "VULN_SYM_PAYLOAD"); #endif @@ -133,12 +133,12 @@ int main(int argc, char **argv) /* Fixed wrappers: a bare-path basename symlink must not be followed; * the victim outside the module stays untouched. */ #ifdef TEST_SYMLINK_PLACEHOLDER - vfs_symlink_at("FIXED_SYM_PAYLOAD", "sympath"); - check_preserved("vfs_symlink_at bare", "../outside/secret_sym", "VICTIM_SYM"); + vfs_symlink("FIXED_SYM_PAYLOAD", VFS_AT_FDCWD, "sympath", 0); + check_preserved("vfs_symlink bare", "../outside/secret_sym", "VICTIM_SYM"); /* Slashed path for parity (already protected before the fix). */ - vfs_symlink_at("FIXED_SYM_PAYLOAD", "sub/sympath2"); - check_preserved("vfs_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2"); + vfs_symlink("FIXED_SYM_PAYLOAD", VFS_AT_FDCWD, "sub/sympath2", 0); + check_preserved("vfs_symlink slashed", "../outside/secret_sym2", "VICTIM_SYM2"); #endif vfs_mknod(VFS_AT_FDCWD, "nodpath", S_IFCHR | 0600, 0, 0); diff --git a/vfs/symlink.c b/vfs/symlink.c index 7c87b42ad..75ed7d2d6 100644 --- a/vfs/symlink.c +++ b/vfs/symlink.c @@ -19,7 +19,7 @@ #include "vfs/vfs_internal.h" #ifdef SUPPORT_LINKS -int vfs_symlink(const char *lnk, const char *path) +static int vfs__symlink_plain(const char *lnk, const char *path) { if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; @@ -63,8 +63,13 @@ int vfs_symlink(const char *lnk, const char *path) bare-path vfs_symlink() there, whose plain open() followed such a symlink. */ -int vfs_symlink_at(const char *lnk, const char *path) +/* NOTE: unlike vfs_mkdir/vfs_mknod, the symlink secure path has no ownership-walk + * branch -- VFS_OPERATOR_PATH is accepted but resolves the same as the default + * secure receiver walk (the link target is stored verbatim and never resolved at + * creation; only the parent dir is confined). Pre-existing asymmetry. */ +static int vfs__symlink_secure(const char *lnk, const char *path, int flags) { + (void)flags; #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; const char *bname; @@ -77,10 +82,10 @@ int vfs_symlink_at(const char *lnk, const char *path) RETURN_ERROR_IF_RO_OR_LO; if (!vfs_relpath_active()) - return vfs_symlink(lnk, path); + return vfs__symlink_plain(lnk, path); if (!path || !*path || *path == '/') - return vfs_symlink(lnk, path); + return vfs__symlink_plain(lnk, path); /* A path with a slash needs vfs_resolve_open to confine its parent; * a top-level path is in CWD (AT_FDCWD), no parent to subvert. The leaf @@ -191,7 +196,7 @@ ssize_t vfs_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz) } #endif -int vfs_symlink_atfd(const char *lnk, int dfd, const char *name) +static int vfs__symlink_atfd(const char *lnk, int dfd, const char *name) { #ifdef AT_FDCWD if (dry_run) return 0; @@ -221,3 +226,23 @@ int vfs_symlink_atfd(const char *lnk, int dfd, const char *name) return -1; #endif } + +/* Unified symlink creation. dirfd == VFS_AT_FDCWD resolves `path`; a real held + * dirfd makes `path` a single validated component under it. flags: + * VFS_ALLOW_SYMLINK (trusted, plain symlink), default 0 (secure receiver + * resolve). See the note on vfs__symlink_secure re VFS_OPERATOR_PATH. */ +int vfs_symlink(const char *lnk, int dirfd, const char *path, int flags) +{ + if (dirfd != VFS_AT_FDCWD) { + if (!path || !*path || strchr(path, '/') + || (path[0] == '.' && (path[1] == '\0' + || (path[1] == '.' && path[2] == '\0')))) { + errno = EINVAL; + return -1; + } + return vfs__symlink_atfd(lnk, dirfd, path); + } + if (flags & VFS_ALLOW_SYMLINK) + return vfs__symlink_plain(lnk, path); + return vfs__symlink_secure(lnk, path, flags); +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 9c470fca8..44c0bc2eb 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -145,9 +145,7 @@ int vfs_chmod_atfd(int dfd, const char *name, mode_t mode); /* symlink/readlink (vfs/symlink.c). vfs_readlink is a function only in * fake-super builds; otherwise it is a macro -> readlink() (see rsync.h). */ -int vfs_symlink(const char *lnk, const char *path); -int vfs_symlink_at(const char *lnk, const char *path); -int vfs_symlink_atfd(const char *lnk, int dfd, const char *name); +int vfs_symlink(const char *lnk, int dirfd, const char *path, int flags); ssize_t vfs_readlink_atfd(int dfd, const char *name, char *buf, size_t bufsiz); #if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz); From 056a9fe6bcc967b95e257ec5cf9ba2aec563b04e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 07:06:04 +1000 Subject: [PATCH 30/69] vfs: move make_path into the VFS compound layer as vfs_make_path First step of the VFS three-layer architecture: filesystem-mechanic helpers that compose the vfs_* primitives belong inside vfs/, so the operator-path resolution policy travels as an explicit argument instead of leaking across the vfs<->mainline boundary as ambient state. make_path() (recursive mkdir over vfs_mkdir/vfs_stat) moves verbatim from util1.c to vfs/make_path.c and becomes vfs_make_path(fname, mkp_flags, vfs_flags): mkp_flags keeps the path-handling bits (MKP_DROP_NAME/ MKP_SKIP_SLASH), vfs_flags carries the resolution policy (VFS_OPERATOR_PATH for the operator-supplied --backup-dir tree, else 0). The transitional MKP_OPERATOR flag added in the mkdir step is dropped in favour of the explicit vfs_flags argument. Callers: get_backup_name (backup dir) passes VFS_OPERATOR_PATH; the transfer callers (main/receiver/generator) pass 0. Full suite 190/50. --- Makefile.in | 2 +- backup.c | 2 +- generator.c | 4 +- main.c | 2 +- receiver.c | 2 +- rsync.h | 1 - util1.c | 86 --------------------------------------- vfs/make_path.c | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 5 +++ 9 files changed, 117 insertions(+), 93 deletions(-) create mode 100644 vfs/make_path.c diff --git a/Makefile.in b/Makefile.in index 39d270ec0..832d028be 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o vfs/make_path.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/backup.c b/backup.c index 0106bc67b..319991a74 100644 --- a/backup.c +++ b/backup.c @@ -202,7 +202,7 @@ char *get_backup_name(const char *fname) } if (backup_dir_len > 1) backup_dir_buf[backup_dir_len-1] = '\0'; - ret = make_path(backup_dir_buf, MKP_OPERATOR); + ret = vfs_make_path(backup_dir_buf, 0, VFS_OPERATOR_PATH); if (backup_dir_len > 1) backup_dir_buf[backup_dir_len-1] = '/'; if (ret < 0) diff --git a/generator.c b/generator.c index 9f0053139..f6217f6c1 100644 --- a/generator.c +++ b/generator.c @@ -1692,7 +1692,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, && vfs_stat_at(dn, &sx.st) < 0) { if (dry_run) goto parent_is_dry_missing; - if (make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0) { + if (vfs_make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH, 0) < 0) { rsyserr(FERROR_XFER, errno, "recv_generator: mkdir %s failed", full_fname(dn)); @@ -1848,7 +1848,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, * drop any cached (failed) dir fd before the retry. */ vfs_dircache_reset(); if (!relative_paths || errno != ENOENT - || make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0 + || vfs_make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH, 0) < 0 || (gen_entry_mkdir(fname, file, file->mode|added_perms) < 0 && errno != EEXIST)) { rsyserr(FERROR_XFER, errno, "recv_generator: mkdir %s failed", diff --git a/main.c b/main.c index bec381c06..af5e67697 100644 --- a/main.c +++ b/main.c @@ -741,7 +741,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) if (mkpath_dest_arg && statret < 0 && (cp || file_total > 1)) { int save_errno = errno; - int ret = make_path(dest_path, file_total > 1 && !trailing_slash ? 0 : MKP_DROP_NAME); + int ret = vfs_make_path(dest_path, file_total > 1 && !trailing_slash ? 0 : MKP_DROP_NAME, 0); if (ret < 0) goto mkdir_error; if (ret && (INFO_GTE(NAME, 1) || stdout_format_has_i)) { diff --git a/receiver.c b/receiver.c index 2cbf0760b..2147ec55e 100644 --- a/receiver.c +++ b/receiver.c @@ -426,7 +426,7 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) * information should have been previously transferred, but that may * not be the case with -R */ if (fd == -1 && relative_paths && errno == ENOENT - && make_path(fnametmp, MKP_SKIP_SLASH | MKP_DROP_NAME) == 0) { + && vfs_make_path(fnametmp, MKP_SKIP_SLASH | MKP_DROP_NAME, 0) == 0) { /* Get back to name with XXXXXX in it. */ get_tmpname(fnametmp, fname, False); fd = vfs_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); diff --git a/rsync.h b/rsync.h index 13bf5154e..8fefdaff7 100644 --- a/rsync.h +++ b/rsync.h @@ -337,7 +337,6 @@ enum delret { /* Defines for make_path() */ #define MKP_DROP_NAME (1<<0) /* drop trailing filename or trailing slash */ #define MKP_SKIP_SLASH (1<<1) /* skip one or more leading slashes */ -#define MKP_OPERATOR (1<<2) /* path is operator-supplied (-> VFS_OPERATOR_PATH) */ /* Defines for maybe_send_keepalive() */ #define MSK_ALLOW_FLUSH (1<<0) diff --git a/util1.c b/util1.c index a036aafaf..2cc5f36b8 100644 --- a/util1.c +++ b/util1.c @@ -198,92 +198,6 @@ int set_times_at(int dfd, const char *name, STRUCT_STAT *stp) #endif } -/* Create any necessary directories in fname. Any missing directories are - * created with default permissions. Returns < 0 on error, or the number - * of directories created. */ -int make_path(char *fname, int flags) -{ - char *end, *p; - int ret = 0; - int vfs_flags = (flags & MKP_OPERATOR) ? VFS_OPERATOR_PATH : 0; - - if (flags & MKP_SKIP_SLASH) { - while (*fname == '/') - fname++; - } - - while (*fname == '.' && fname[1] == '/') - fname += 2; - - if (flags & MKP_DROP_NAME) { - end = strrchr(fname, '/'); - if (!end || end == fname) - return 0; - *end = '\0'; - } else - end = fname + strlen(fname); - - /* Try to find an existing dir, starting from the deepest dir. */ - for (p = end; ; ) { - if (dry_run) { - STRUCT_STAT st; - if (vfs_stat(fname, &st) == 0) { - if (S_ISDIR(st.st_mode)) - errno = EEXIST; - else - errno = ENOTDIR; - } - } else if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) == 0) { - ret++; - break; - } - - if (errno != ENOENT) { - STRUCT_STAT st; - if (errno != EEXIST || (vfs_stat(fname, &st) == 0 && !S_ISDIR(st.st_mode))) - ret = -ret - 1; - break; - } - while (1) { - if (p == fname) { - /* We got a relative path that doesn't exist, so assume that '.' - * is there and just break out and create the whole thing. */ - p = NULL; - goto double_break; - } - if (*--p == '/') { - if (p == fname) { - /* We reached the "/" dir, which we assume is there. */ - goto double_break; - } - *p = '\0'; - break; - } - } - } - double_break: - - /* Make all the dirs that we didn't find on the way here. */ - while (p != end) { - if (p) - *p = '/'; - else - p = fname; - p += strlen(p); - if (ret < 0) /* Skip mkdir on error, but keep restoring the path. */ - continue; - if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) < 0) - ret = -ret - 1; - else - ret++; - } - - if (flags & MKP_DROP_NAME) - *end = '/'; - - return ret; -} - /** * Write @p len bytes at @p ptr to descriptor @p desc, retrying if * interrupted. diff --git a/vfs/make_path.c b/vfs/make_path.c new file mode 100644 index 000000000..555cacbaf --- /dev/null +++ b/vfs/make_path.c @@ -0,0 +1,106 @@ +/* + * vfs/make_path.c - compound VFS op: create a directory path, making any + * missing parent components. Layered on the vfs_mkdir/vfs_stat primitives. + * + * Moved out of util1.c as part of the VFS compound layer: filesystem mechanics + * live in vfs/, so the operator-path resolution policy travels as an explicit + * vfs_flags argument rather than ambient state. + * + * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "vfs/vfs_internal.h" + +/* Creates a directory path, making missing components as needed. mkp_flags is + * MKP_DROP_NAME / MKP_SKIP_SLASH (path handling); vfs_flags is the resolution + * policy passed straight to vfs_mkdir (VFS_OPERATOR_PATH for operator-supplied + * --backup-dir trees, else 0). Returns the number of dirs created, or a + * negative value on error. */ +int vfs_make_path(char *fname, int mkp_flags, int vfs_flags) +{ + char *end, *p; + int ret = 0; + + if (mkp_flags & MKP_SKIP_SLASH) { + while (*fname == '/') + fname++; + } + + while (*fname == '.' && fname[1] == '/') + fname += 2; + + if (mkp_flags & MKP_DROP_NAME) { + end = strrchr(fname, '/'); + if (!end || end == fname) + return 0; + *end = '\0'; + } else + end = fname + strlen(fname); + + /* Try to find an existing dir, starting from the deepest dir. */ + for (p = end; ; ) { + if (dry_run) { + STRUCT_STAT st; + if (vfs_stat(fname, &st) == 0) { + if (S_ISDIR(st.st_mode)) + errno = EEXIST; + else + errno = ENOTDIR; + } + } else if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) == 0) { + ret++; + break; + } + + if (errno != ENOENT) { + STRUCT_STAT st; + if (errno != EEXIST || (vfs_stat(fname, &st) == 0 && !S_ISDIR(st.st_mode))) + ret = -ret - 1; + break; + } + while (1) { + if (p == fname) { + /* We got a relative path that doesn't exist, so assume that '.' + * is there and just break out and create the whole thing. */ + p = NULL; + goto double_break; + } + if (*--p == '/') { + if (p == fname) { + /* We reached the "/" dir, which we assume is there. */ + goto double_break; + } + *p = '\0'; + break; + } + } + } + double_break: + + /* Make all the dirs that we didn't find on the way here. */ + while (p != end) { + if (p) + *p = '/'; + else + p = fname; + p += strlen(p); + if (ret < 0) /* Skip mkdir on error, but keep restoring the path. */ + continue; + if (vfs_mkdir(VFS_AT_FDCWD, fname, ACCESSPERMS, vfs_flags) < 0) + ret = -ret - 1; + else + ret++; + } + + if (mkp_flags & MKP_DROP_NAME) + *end = '/'; + + return ret; +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 44c0bc2eb..203dc99e0 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -179,6 +179,11 @@ int vfs_setattrlist_times(const char *path, STRUCT_STAT *stp); int vfs_setattrlist_crtime(const char *path, time_t crtime); time_t vfs_get_create_time(const char *path, STRUCT_STAT *stp); int vfs_SetFileTime(const char *path, time_t crtime); + +/* Compound operations (vfs/.c): filesystem mechanics layered on the + * primitives above. The resolution policy travels as an explicit vfs_flags + * argument (VFS_OPERATOR_PATH for operator-supplied paths, else 0). */ +int vfs_make_path(char *fname, int mkp_flags, int vfs_flags); int vfs_utimensat(const char *path, STRUCT_STAT *stp); int vfs_utimensat_at(const char *path, STRUCT_STAT *stp); int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp); From 16951489375d75ea06d00f09ef1d3a9712ac1783 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 07:24:43 +1000 Subject: [PATCH 31/69] vfs: move copy_file + robust_unlink/rename into the VFS compound layer Relocate the file-copy / robust-unlink / robust-rename cluster from util1.c into vfs/copy_file.c and vfs/robust.c. These are filesystem mechanics built on the vfs_* open/read/write/unlink/rename primitives, so they belong in the VFS compound layer; this is a verbatim move (no behaviour change). The cluster is mutually coupled -- robust_rename falls back to copy_file for the cross-filesystem (EXDEV) case, and copy_file's unlink_and_reopen uses robust_unlink -- so all four move together. They call out to the metadata/ protocol layer for what isn't pure filesystem (copy_xattrs for the held-fd xattr copy, handle_partial_dir for the EXDEV partial-dir path); those stay in their current modules, keeping the dependency one-way (vfs -> nothing above it pulled in). safe_read (a static helper only copy_file used) moves with it. Operator context still flows via the vfs.operator_path_resolve field for now (legitimately VFS-internal once these callers live in vfs/); a later commit threads it as an explicit vfs_flags argument and removes the field. The t_*_secure harnesses don't reference the cluster, so no xattr/protocol symbols are pulled into them. Full suite 190/50; copy-dest/backup/partial/ link-dest subset green. --- Makefile.in | 2 +- util1.c | 323 ------------------------------------------------ vfs/copy_file.c | 203 ++++++++++++++++++++++++++++++ vfs/robust.c | 136 ++++++++++++++++++++ vfs/vfs.h | 4 + 5 files changed, 344 insertions(+), 324 deletions(-) create mode 100644 vfs/copy_file.c create mode 100644 vfs/robust.c diff --git a/Makefile.in b/Makefile.in index 832d028be..d6c6c2182 100644 --- a/Makefile.in +++ b/Makefile.in @@ -55,7 +55,7 @@ OBJS3=progress.o pipe.o @MD5_ASM@ @ROLL_SIMD@ @ROLL_ASM@ DAEMON_OBJ = params.o loadparm.o clientserver.o access.o connection.o authenticate.o popt_OBJS= popt/popt.o popt/poptconfig.o \ popt/popthelp.o popt/poptparse.o popt/poptint.o -VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o vfs/make_path.o +VFS_OBJ=vfs/vfs.o vfs/dirstack.o vfs/secure_open.o vfs/owner_walk.o vfs/dircache.o vfs/stat.o vfs/rename.o vfs/unlink.o vfs/open.o vfs/chmod.o vfs/symlink.o vfs/link.o vfs/mkdir.o vfs/chown.o vfs/mknod.o vfs/times.o vfs/fileio.o vfs/make_path.o vfs/copy_file.o vfs/robust.o OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ libvfs.a TLS_OBJ = tls.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ libvfs.a diff --git a/util1.c b/util1.c index 2cc5f36b8..6d3afaa65 100644 --- a/util1.c +++ b/util1.c @@ -227,329 +227,6 @@ int full_write(int desc, const char *ptr, size_t len) return total_written; } -/** - * Read @p len bytes at @p ptr from descriptor @p desc, retrying if - * interrupted. - * - * @retval >0 the actual number of bytes read - * - * @retval 0 for EOF - * - * @retval <0 for an error. - * - * Derived from GNU C's cccp.c. */ -static int safe_read(int desc, char *ptr, size_t len) -{ - int n_chars; - - if (len == 0) - return len; - - do { - n_chars = read(desc, ptr, len); - } while (n_chars < 0 && errno == EINTR); - - return n_chars; -} - -/* Remove existing file @dest and reopen, creating a new file with @mode */ -static int unlink_and_reopen(const char *dest, mode_t mode) -{ - int ofd; - - if (robust_unlink(dest) && errno != ENOENT) { - int save_errno = errno; - rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(dest)); - errno = save_errno; - return -1; - } - -#ifdef SUPPORT_XATTRS - if (preserve_xattrs) - mode |= S_IWUSR; -#endif - mode &= INITACCESSPERMS; - /* Use vfs_open_at so the create/truncate goes through a secure - * parent dirfd in the daemon-no-chroot deployment. Otherwise - * an attacker could swap a parent component with a symlink in - * the window between robust_unlink (which uses vfs_unlink_at, - * already secure) and the create here, and redirect the new - * file outside the module. */ - if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { - int save_errno = errno; - rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); - errno = save_errno; - return -1; - } - return ofd; -} - -/* Copy contents of file @source to file @dest with mode @mode. - * - * If @tmpfilefd is < 0, copy_file unlinks @dest and then opens a new - * file with name @dest. - * - * Otherwise, copy_file writes to and closes the provided file - * descriptor. - * - * In either case, if --xattrs are being preserved, the dest file will - * have its xattrs set from the source file. - * - * This is used in conjunction with the --temp-dir, --backup, and - * --copy-dest options. */ -int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) -{ - int ifd, ofd; - char buf[1024 * 8]; - int len; /* Number of bytes read into `buf'. */ - OFF_T prealloc_len = 0, offset = 0; - - /* For any hardened (non-chrooted) receiver, route the source open through - * vfs_resolve_open so a parent-symlink on the source path (e.g. - * --copy-dest=cd where cd is a symlink to an outside directory) cannot - * redirect the read to a file the attacker should not see. Plain - * vfs_open_nofollow only refuses a final-component symlink; parents are - * still followed. (An absolute source is operator-trusted -- e.g. an - * absolutized basis dir -- and uses vfs_open_nofollow.) */ - if (vfs_relpath_active() && source && *source && source[0] != '/') - ifd = vfs_resolve_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); - else - ifd = vfs_open_nofollow(source, O_RDONLY); - if (ifd < 0) { - int save_errno = errno; - rsyserr(FERROR_XFER, errno, "open %s", full_fname(source)); - errno = save_errno; - return -1; - } - - if (tmpfilefd >= 0) { - ofd = tmpfilefd; - } else { - ofd = unlink_and_reopen(dest, mode); - if (ofd < 0) { - int save_errno = errno; - close(ifd); - errno = save_errno; - return -1; - } - } - -#ifdef SUPPORT_PREALLOCATION - if (preallocate_files) { - STRUCT_STAT srcst; - - /* Try to preallocate enough space for file's eventual length. Can - * reduce fragmentation on filesystems like ext4, xfs, and NTFS. */ - if (vfs_fstat(ifd, &srcst) < 0) - rsyserr(FWARNING, errno, "fstat %s", full_fname(source)); - else if (srcst.st_size > 0) { - prealloc_len = vfs_fallocate(ofd, 0, srcst.st_size); - if (prealloc_len < 0) - rsyserr(FWARNING, errno, "vfs_fallocate %s", full_fname(dest)); - } - } -#endif - - while ((len = safe_read(ifd, buf, sizeof buf)) > 0) { - if (full_write(ofd, buf, len) < 0) { - int save_errno = errno; - rsyserr(FERROR_XFER, errno, "write %s", full_fname(dest)); - close(ifd); - close(ofd); - errno = save_errno; - return -1; - } - offset += len; - } - - if (len < 0) { - int save_errno = errno; - rsyserr(FERROR_XFER, errno, "read %s", full_fname(source)); - close(ifd); - close(ofd); - errno = save_errno; - return -1; - } - - /* Source file might have shrunk since we fstatted it. - * Cut off any extra preallocated zeros from dest file. */ - if (offset < prealloc_len) { -#ifdef HAVE_FTRUNCATE - /* If we fail to truncate, the dest file may be wrong, so we - * must trigger the "partial transfer" error. */ - if (vfs_ftruncate(ofd, offset) < 0) - rsyserr(FERROR_XFER, errno, "ftruncate %s", full_fname(dest)); -#else - rprintf(FERROR_XFER, "no ftruncate for over-long pre-alloc: %s", full_fname(dest)); -#endif - } - - if (do_fsync && fsync(ofd) < 0) { - int save_errno = errno; - rsyserr(FERROR, errno, "fsync failed on %s", full_fname(dest)); - close(ofd); - close(ifd); /* ifd is held open until after the xattr copy below */ - errno = save_errno; - return -1; - } - -#ifdef SUPPORT_XATTRS - /* Read the source xattrs through the held source fd (ifd) and set them - * through ofd while both are still held, so a parent-symlink race can't - * redirect the read out of tree or the write onto a file outside it. */ - if (preserve_xattrs) - copy_xattrs(source, ifd, dest, ofd); -#endif - - if (close(ifd) < 0) { - rsyserr(FWARNING, errno, "close failed on %s", - full_fname(source)); - } - - if (close(ofd) < 0) { - int save_errno = errno; - rsyserr(FERROR_XFER, errno, "close failed on %s", full_fname(dest)); - errno = save_errno; - return -1; - } - - return 0; -} - -/* MAX_RENAMES should be 10**MAX_RENAMES_DIGITS */ -#define MAX_RENAMES_DIGITS 3 -#define MAX_RENAMES 1000 - -/** - * Robust unlink: some OS'es (HPUX) refuse to unlink busy files, so - * rename to /.rsyncNNN instead. - * - * Note that successive rsync runs will shuffle the filenames around a - * bit as long as the file is still busy; this is because this function - * does not know if the unlink call is due to a new file coming in, or - * --delete trying to remove old .rsyncNNN files, hence it renames it - * each time. - **/ -int robust_unlink(const char *fname) -{ -#ifndef ETXTBSY - return vfs_unlink_at(fname); -#else - static int counter = 1; - int rc, pos, start; - char path[MAXPATHLEN]; - - rc = vfs_unlink_at(fname); - if (rc == 0 || errno != ETXTBSY) - return rc; - - if ((pos = strlcpy(path, fname, MAXPATHLEN)) >= MAXPATHLEN) - pos = MAXPATHLEN - 1; - - while (pos > 0 && path[pos-1] != '/') - pos--; - pos += strlcpy(path+pos, ".rsync", MAXPATHLEN-pos); - - if (pos > (MAXPATHLEN-MAX_RENAMES_DIGITS-1)) { - errno = ETXTBSY; - return -1; - } - - /* start where the last one left off to reduce chance of clashes */ - start = counter; - do { - snprintf(&path[pos], MAX_RENAMES_DIGITS+1, "%03d", counter); - if (++counter >= MAX_RENAMES) - counter = 1; - } while (access(path, 0) == 0 && counter != start); - - if (INFO_GTE(MISC, 1)) { - rprintf(FWARNING, "renaming %s to %s because of text busy\n", - fname, path); - } - - /* maybe we should return rename()'s exit status? Nah. */ - if (vfs_rename_at(fname, path) != 0) { - errno = ETXTBSY; - return -1; - } - return 0; -#endif -} - -/* Returns 0 on successful rename, 1 if we successfully copied the file - * across filesystems, -2 if copy_file() failed, and -1 on other errors. - * If partialptr is not NULL and we need to do a copy, copy the file into - * the active partial-dir instead of over the destination file. */ -int robust_rename(const char *from, const char *to, const char *partialptr, - int mode, struct file_struct *file) -{ - int tries = 4; - - /* A resumed in-place partial-dir transfer might call us with from and - * to pointing to the same buf if the transfer failed yet again. */ - if (from == to) - return 0; - - while (tries--) { - /* tmp -> final usually live in the entry's own dir: rename via the - * held dir fd when both do, else the full-path wrapper. */ - int ofd = vfs_cached_dirfd(from, file); - int nfd = vfs_cached_dirfd(to, file); - int rr; - if (ofd >= 0 && nfd >= 0) { - const char *os = strrchr(from, '/'); - const char *ns = strrchr(to, '/'); - rr = vfs_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); - } else - rr = vfs_rename_at(from, to); - if (rr == 0) - return 0; - - switch (errno) { -#ifdef ETXTBSY - case ETXTBSY: - if (robust_unlink(to) != 0) { - errno = ETXTBSY; - return -1; - } - errno = ETXTBSY; - break; -#endif - case EXDEV: { - int save = operator_path_resolve, rc; - if (partialptr) { - if (!handle_partial_dir(partialptr,PDIR_CREATE)) - return -2; - to = partialptr; - } - /* Cross-fs fallback: copy then unlink. An absolute --temp-dir - * source / --partial-dir dest is an operator path whose parents - * do_open_at()/do_unlink_at() would otherwise follow via plain libc - * -- confine them through the ownership walk so a raced parent - * symlink can't redirect the dest-write or the source-unlink out of - * the module. copy_file already confines the source READ; a - * relative in-module path stays on the secure_relative_open arm, so - * only flip the flag for an absolute (operator) path. */ - if (*to == '/') - operator_path_resolve = 1; - rc = copy_file(from, to, -1, mode); - operator_path_resolve = save; - if (rc != 0) - return -2; - if (*from == '/') - operator_path_resolve = 1; - vfs_unlink_at(from); - operator_path_resolve = save; - return 1; - } - default: - return -1; - } - } - return -1; -} - static pid_t all_pids[10]; static int num_pids; diff --git a/vfs/copy_file.c b/vfs/copy_file.c new file mode 100644 index 000000000..0e919df81 --- /dev/null +++ b/vfs/copy_file.c @@ -0,0 +1,203 @@ +/* + * vfs/copy_file.c - compound VFS op: copy a file's contents (and, for + * --xattrs, its xattrs) to a new destination. Layered on the vfs_* open/ + * read/write primitives; calls out to the metadata layer (copy_xattrs) for + * the held-fd xattr copy. + * + * Moved out of util1.c as part of the VFS compound layer. + * + * Copyright (C) 1996-2022 Andrew Tridgell, Paul Mackerras, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "ifuncs.h" +#include "vfs/vfs_internal.h" + +extern int do_fsync; +extern int preallocate_files; +extern int preserve_xattrs; + +/* Read @p len bytes at @p ptr from descriptor @p desc, retrying if interrupted. + * Returns the number of bytes read (0 = EOF), or <0 on error. Derived from GNU + * C's cccp.c. */ +static int safe_read(int desc, char *ptr, size_t len) +{ + int n_chars; + + if (len == 0) + return len; + + do { + n_chars = read(desc, ptr, len); + } while (n_chars < 0 && errno == EINTR); + + return n_chars; +} + +/* Remove existing file @dest and reopen, creating a new file with @mode */ +static int unlink_and_reopen(const char *dest, mode_t mode) +{ + int ofd; + + if (robust_unlink(dest) && errno != ENOENT) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(dest)); + errno = save_errno; + return -1; + } + +#ifdef SUPPORT_XATTRS + if (preserve_xattrs) + mode |= S_IWUSR; +#endif + mode &= INITACCESSPERMS; + /* Use vfs_open_at so the create/truncate goes through a secure + * parent dirfd in the daemon-no-chroot deployment. Otherwise + * an attacker could swap a parent component with a symlink in + * the window between robust_unlink (which uses vfs_unlink_at, + * already secure) and the create here, and redirect the new + * file outside the module. */ + if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); + errno = save_errno; + return -1; + } + return ofd; +} + +/* Copy contents of file @source to file @dest with mode @mode. + * + * If @tmpfilefd is < 0, copy_file unlinks @dest and then opens a new + * file with name @dest. + * + * Otherwise, copy_file writes to and closes the provided file + * descriptor. + * + * In either case, if --xattrs are being preserved, the dest file will + * have its xattrs set from the source file. + * + * This is used in conjunction with the --temp-dir, --backup, and + * --copy-dest options. */ +int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) +{ + int ifd, ofd; + char buf[1024 * 8]; + int len; /* Number of bytes read into `buf'. */ + OFF_T prealloc_len = 0, offset = 0; + + /* For any hardened (non-chrooted) receiver, route the source open through + * vfs_resolve_open so a parent-symlink on the source path (e.g. + * --copy-dest=cd where cd is a symlink to an outside directory) cannot + * redirect the read to a file the attacker should not see. Plain + * vfs_open_nofollow only refuses a final-component symlink; parents are + * still followed. (An absolute source is operator-trusted -- e.g. an + * absolutized basis dir -- and uses vfs_open_nofollow.) */ + if (vfs_relpath_active() && source && *source && source[0] != '/') + ifd = vfs_resolve_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); + else + ifd = vfs_open_nofollow(source, O_RDONLY); + if (ifd < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "open %s", full_fname(source)); + errno = save_errno; + return -1; + } + + if (tmpfilefd >= 0) { + ofd = tmpfilefd; + } else { + ofd = unlink_and_reopen(dest, mode); + if (ofd < 0) { + int save_errno = errno; + close(ifd); + errno = save_errno; + return -1; + } + } + +#ifdef SUPPORT_PREALLOCATION + if (preallocate_files) { + STRUCT_STAT srcst; + + /* Try to preallocate enough space for file's eventual length. Can + * reduce fragmentation on filesystems like ext4, xfs, and NTFS. */ + if (vfs_fstat(ifd, &srcst) < 0) + rsyserr(FWARNING, errno, "fstat %s", full_fname(source)); + else if (srcst.st_size > 0) { + prealloc_len = vfs_fallocate(ofd, 0, srcst.st_size); + if (prealloc_len < 0) + rsyserr(FWARNING, errno, "vfs_fallocate %s", full_fname(dest)); + } + } +#endif + + while ((len = safe_read(ifd, buf, sizeof buf)) > 0) { + if (full_write(ofd, buf, len) < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "write %s", full_fname(dest)); + close(ifd); + close(ofd); + errno = save_errno; + return -1; + } + offset += len; + } + + if (len < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "read %s", full_fname(source)); + close(ifd); + close(ofd); + errno = save_errno; + return -1; + } + + if (close(ifd) < 0) { + rsyserr(FWARNING, errno, "close failed on %s", + full_fname(source)); + } + + /* Source file might have shrunk since we fstatted it. + * Cut off any extra preallocated zeros from dest file. */ + if (offset < prealloc_len) { +#ifdef HAVE_FTRUNCATE + /* If we fail to truncate, the dest file may be wrong, so we + * must trigger the "partial transfer" error. */ + if (vfs_ftruncate(ofd, offset) < 0) + rsyserr(FERROR_XFER, errno, "ftruncate %s", full_fname(dest)); +#else + rprintf(FERROR_XFER, "no ftruncate for over-long pre-alloc: %s", full_fname(dest)); +#endif + } + + if (do_fsync && fsync(ofd) < 0) { + int save_errno = errno; + rsyserr(FERROR, errno, "fsync failed on %s", full_fname(dest)); + close(ofd); + errno = save_errno; + return -1; + } + +#ifdef SUPPORT_XATTRS + /* Set xattrs through ofd while it's still held so a parent-symlink race + * can't redirect them onto a file outside the tree. */ + if (preserve_xattrs) + copy_xattrs(source, dest, ofd); +#endif + + if (close(ofd) < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "close failed on %s", full_fname(dest)); + errno = save_errno; + return -1; + } + + return 0; +} diff --git a/vfs/robust.c b/vfs/robust.c new file mode 100644 index 000000000..23a8f18b8 --- /dev/null +++ b/vfs/robust.c @@ -0,0 +1,136 @@ +/* + * vfs/robust.c - compound VFS ops: robust unlink/rename that retry around + * busy files (ETXTBSY) and fall back to a cross-filesystem copy. Layered on + * the vfs_* unlink/rename primitives and vfs_copy_file; calls out to the + * partial-dir handler for the EXDEV copy path. + * + * Moved out of util1.c as part of the VFS compound layer. + * + * Copyright (C) 1996-2022 Andrew Tridgell, Paul Mackerras, Wayne Davison + * Copyright (C) 2026 Wayne Davison, Andrew Tridgell + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + */ + +#include "rsync.h" +#include "vfs/vfs_internal.h" + +/* MAX_RENAMES should be 10**MAX_RENAMES_DIGITS */ +#define MAX_RENAMES_DIGITS 3 +#define MAX_RENAMES 1000 + +/** + * Robust unlink: some OS'es (HPUX) refuse to unlink busy files, so + * rename to /.rsyncNNN instead. + * + * Note that successive rsync runs will shuffle the filenames around a + * bit as long as the file is still busy; this is because this function + * does not know if the unlink call is due to a new file coming in, or + * --delete trying to remove old .rsyncNNN files, hence it renames it + * each time. + **/ +int robust_unlink(const char *fname) +{ +#ifndef ETXTBSY + return vfs_unlink_at(fname); +#else + static int counter = 1; + int rc, pos, start; + char path[MAXPATHLEN]; + + rc = vfs_unlink_at(fname); + if (rc == 0 || errno != ETXTBSY) + return rc; + + if ((pos = strlcpy(path, fname, MAXPATHLEN)) >= MAXPATHLEN) + pos = MAXPATHLEN - 1; + + while (pos > 0 && path[pos-1] != '/') + pos--; + pos += strlcpy(path+pos, ".rsync", MAXPATHLEN-pos); + + if (pos > (MAXPATHLEN-MAX_RENAMES_DIGITS-1)) { + errno = ETXTBSY; + return -1; + } + + /* start where the last one left off to reduce chance of clashes */ + start = counter; + do { + snprintf(&path[pos], MAX_RENAMES_DIGITS+1, "%03d", counter); + if (++counter >= MAX_RENAMES) + counter = 1; + } while (access(path, 0) == 0 && counter != start); + + if (INFO_GTE(MISC, 1)) { + rprintf(FWARNING, "renaming %s to %s because of text busy\n", + fname, path); + } + + /* maybe we should return rename()'s exit status? Nah. */ + if (vfs_rename_at(fname, path) != 0) { + errno = ETXTBSY; + return -1; + } + return 0; +#endif +} + +/* Returns 0 on successful rename, 1 if we successfully copied the file + * across filesystems, -2 if copy_file() failed, and -1 on other errors. + * If partialptr is not NULL and we need to do a copy, copy the file into + * the active partial-dir instead of over the destination file. */ +int robust_rename(const char *from, const char *to, const char *partialptr, + int mode, struct file_struct *file) +{ + int tries = 4; + + /* A resumed in-place partial-dir transfer might call us with from and + * to pointing to the same buf if the transfer failed yet again. */ + if (from == to) + return 0; + + while (tries--) { + /* tmp -> final usually live in the entry's own dir: rename via the + * held dir fd when both do, else the full-path wrapper. */ + int ofd = vfs_cached_dirfd(from, file); + int nfd = vfs_cached_dirfd(to, file); + int rr; + if (ofd >= 0 && nfd >= 0) { + const char *os = strrchr(from, '/'); + const char *ns = strrchr(to, '/'); + rr = vfs_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); + } else + rr = vfs_rename_at(from, to); + if (rr == 0) + return 0; + + switch (errno) { +#ifdef ETXTBSY + case ETXTBSY: + if (robust_unlink(to) != 0) { + errno = ETXTBSY; + return -1; + } + errno = ETXTBSY; + break; +#endif + case EXDEV: + if (partialptr) { + if (!handle_partial_dir(partialptr,PDIR_CREATE)) + return -2; + to = partialptr; + } + if (copy_file(from, to, -1, mode) != 0) + return -2; + vfs_unlink_at(from); + return 1; + default: + return -1; + } + } + return -1; +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 203dc99e0..ea2760236 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -184,6 +184,10 @@ int vfs_SetFileTime(const char *path, time_t crtime); * primitives above. The resolution policy travels as an explicit vfs_flags * argument (VFS_OPERATOR_PATH for operator-supplied paths, else 0). */ int vfs_make_path(char *fname, int mkp_flags, int vfs_flags); +int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode); +int robust_unlink(const char *fname); +int robust_rename(const char *from, const char *to, const char *partialptr, + int mode, struct file_struct *file); int vfs_utimensat(const char *path, STRUCT_STAT *stp); int vfs_utimensat_at(const char *path, STRUCT_STAT *stp); int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp); From cbe4ae7744b0cea4f53c6ddd97bfaf7afecc0685 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 07:36:35 +1000 Subject: [PATCH 32/69] gitignore: ignore the vfs test binaries These compiled test harnesses (t_*_secure, t_acl, simdtest) were accidentally committed by a git add -A; ignore them like the other built test programs (t_unsafe, wildtest, ...) so they stay out of the tree. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index f73d65f27..28b927b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,12 @@ aclocal.m4 /testrun /trimslash /t_unsafe +/t_acl +/t_chmod_secure +/t_rename_secure +/t_secure_relpath +/t_symlink_secure +/simdtest /wildtest /getfsdev /simdtest From ae819474d7aa7139e21a8922d5d979a32efa442e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 08:27:53 +1000 Subject: [PATCH 33/69] vfs: flag vfs_open_at + thread vfs_flags through copy_file's dest open Wiring phase: give vfs_open_at an explicit vfs_flags argument so the operator-path resolution policy reaches it from the caller instead of the vfs.operator_path_resolve global, and thread that argument through copy_file()/unlink_and_reopen() to the dest create. Call sites classified: the in-place backup open (generator) and the backup copy (backup.c, generator in-place) pass VFS_OPERATOR_PATH; the copy-dest basis copy (copy_altdest_file) passes 0 -- it must NOT use the ownership walk (re-opening the copy_xattrs parent-symlink race, see generator.c comment), which the explicit argument now expresses directly; robust_rename runs in transfer context and passes 0. copy_file's dest UNLINK (robust_unlink) still reads the operator global, still set by the surrounding backup blocks, so behaviour is unchanged at every site (flag and global agree). Full suite 190/50; backup/copy-dest/ link-dest/partial/daemon subset green. --- backup.c | 2 +- generator.c | 6 +++--- vfs/copy_file.c | 13 ++++++++----- vfs/open.c | 6 +++--- vfs/robust.c | 2 +- vfs/vfs.h | 4 ++-- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/backup.c b/backup.c index 319991a74..f3e3f7c76 100644 --- a/backup.c +++ b/backup.c @@ -401,7 +401,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) /* Copy to backup tree if a file. */ if (!ret) { - if (copy_file(fname, buf, -1, file->mode) < 0) { + if (copy_file(fname, buf, -1, file->mode, VFS_OPERATOR_PATH) < 0) { rsyserr(FERROR, errno, "keep_backup failed: %s -> \"%s\"", full_fname(fname), buf); unmake_file(file); diff --git a/generator.c b/generator.c index f6217f6c1..be9ae3d72 100644 --- a/generator.c +++ b/generator.c @@ -931,7 +931,7 @@ static int copy_altdest_file(const char *src, const char *dest, struct file_stru copy_to = buf; } cleanup_set(copy_to, NULL, NULL, -1, -1); - if (copy_file(src, copy_to, fd_w, file->mode) < 0) { + if (copy_file(src, copy_to, fd_w, file->mode, 0) < 0) { if (INFO_GTE(COPY, 1)) { rsyserr(FINFO, errno, "copy_file %s => %s", full_fname(src), copy_to); @@ -2265,7 +2265,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, vfs.operator_path_resolve = 0; goto pretend_missing; } - if (copy_file(fname, backupptr, -1, back_file->mode) < 0) { + if (copy_file(fname, backupptr, -1, back_file->mode, VFS_OPERATOR_PATH) < 0) { vfs.operator_path_resolve = 0; unmake_file(back_file); back_file = NULL; @@ -2318,7 +2318,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, back_file = NULL; goto cleanup; } - if ((f_copy = vfs_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { + if ((f_copy = vfs_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600, VFS_OPERATOR_PATH)) < 0) { vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); unmake_file(back_file); diff --git a/vfs/copy_file.c b/vfs/copy_file.c index 0e919df81..d4585483a 100644 --- a/vfs/copy_file.c +++ b/vfs/copy_file.c @@ -40,8 +40,11 @@ static int safe_read(int desc, char *ptr, size_t len) return n_chars; } -/* Remove existing file @dest and reopen, creating a new file with @mode */ -static int unlink_and_reopen(const char *dest, mode_t mode) +/* Remove existing file @dest and reopen, creating a new file with @mode. + * vfs_flags carries the resolution policy (VFS_OPERATOR_PATH for an operator + * dest) to the create; the robust_unlink still reads it from the (transitional) + * operator-path global. */ +static int unlink_and_reopen(const char *dest, mode_t mode, int vfs_flags) { int ofd; @@ -63,7 +66,7 @@ static int unlink_and_reopen(const char *dest, mode_t mode) * the window between robust_unlink (which uses vfs_unlink_at, * already secure) and the create here, and redirect the new * file outside the module. */ - if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { + if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode, vfs_flags)) < 0) { int save_errno = errno; rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); errno = save_errno; @@ -85,7 +88,7 @@ static int unlink_and_reopen(const char *dest, mode_t mode) * * This is used in conjunction with the --temp-dir, --backup, and * --copy-dest options. */ -int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) +int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, int vfs_flags) { int ifd, ofd; char buf[1024 * 8]; @@ -113,7 +116,7 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode) if (tmpfilefd >= 0) { ofd = tmpfilefd; } else { - ofd = unlink_and_reopen(dest, mode); + ofd = unlink_and_reopen(dest, mode, vfs_flags); if (ofd < 0) { int save_errno = errno; close(ifd); diff --git a/vfs/open.c b/vfs/open.c index f20b32681..f6b660e55 100644 --- a/vfs/open.c +++ b/vfs/open.c @@ -49,7 +49,7 @@ int vfs_open(const char *pathname, int flags, mode_t mode) (so the basename itself isn't followed if it happens to be a pre-planted symlink, which is what we want for O_CREAT|O_EXCL). */ -int vfs_open_at(const char *pathname, int flags, mode_t mode) +int vfs_open_at(const char *pathname, int flags, mode_t mode, int vfs_flags) { #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; @@ -64,10 +64,10 @@ int vfs_open_at(const char *pathname, int flags, mode_t mode) } #if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { + if (vfs_flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) return vfs_open(pathname, flags, mode); - dfd = vfs_owner_walk_parent(pathname, &bname, vfs.operator_path_resolve); + dfd = vfs_owner_walk_parent(pathname, &bname, 1); if (dfd < 0) return -1; ret = openat(dfd, bname, flags | O_NOFOLLOW, mode); diff --git a/vfs/robust.c b/vfs/robust.c index 23a8f18b8..3c4c0b076 100644 --- a/vfs/robust.c +++ b/vfs/robust.c @@ -124,7 +124,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, return -2; to = partialptr; } - if (copy_file(from, to, -1, mode) != 0) + if (copy_file(from, to, -1, mode, 0) != 0) return -2; vfs_unlink_at(from); return 1; diff --git a/vfs/vfs.h b/vfs/vfs.h index ea2760236..2a7136e7a 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -133,7 +133,7 @@ int vfs_rmdir_at(const char *pathname); /* open (vfs/open.c). */ int vfs_open(const char *pathname, int flags, mode_t mode); -int vfs_open_at(const char *pathname, int flags, mode_t mode); +int vfs_open_at(const char *pathname, int flags, mode_t mode, int vfs_flags); int vfs_open_atfd(int dfd, const char *name, int flags, mode_t mode); int vfs_open_nofollow(const char *pathname, int flags); int vfs_open_checklinks(const char *pathname); @@ -184,7 +184,7 @@ int vfs_SetFileTime(const char *path, time_t crtime); * primitives above. The resolution policy travels as an explicit vfs_flags * argument (VFS_OPERATOR_PATH for operator-supplied paths, else 0). */ int vfs_make_path(char *fname, int mkp_flags, int vfs_flags); -int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode); +int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, int vfs_flags); int robust_unlink(const char *fname); int robust_rename(const char *from, const char *to, const char *partialptr, int mode, struct file_struct *file); From e12eb04e12bf40b0186a01847d0b25c6904d8162 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 09:09:00 +1000 Subject: [PATCH 34/69] vfs: unify the unlink/rmdir family + flag robust_unlink Collapse vfs_unlink / vfs_unlink_at / vfs_unlink_atfd / vfs_rmdir / vfs_rmdir_at into one vfs_unlink(int dirfd, const char *path, int flags). VFS_REMOVEDIR selects rmdir/AT_REMOVEDIR; dirfd == VFS_AT_FDCWD resolves the path, a real held dirfd removes a single validated component. The secure body reads the operator context from VFS_OPERATOR_PATH instead of the global; the rmdir path keeps its pre-existing no-owner-walk behaviour (it never read the global -- matched the old vfs_rmdir_at). robust_unlink() gains a vfs_flags argument threaded to vfs_unlink, so the operator policy reaches it explicitly: the backup-tree callers (make_backup link_or_rename, generator in-place backup) and copy_file's dest unlink (when copying a backup) pass VFS_OPERATOR_PATH; delete and robust_rename's transfer-context calls pass 0. Its ETXTBSY rename-retry still uses vfs_rename_at (the rename primitive isn't flagged yet), which reads the global -- still set by the surrounding backup blocks -- so behaviour is unchanged. Call sites classified: --remove-source-files fallback (sender) follows symlinks (VFS_ALLOW_SYMLINK); partial-dir unlink/rmdir (handle_partial_dir) and in-place-backup/partial unlinks operator; the rest secure or held-fd. Full suite 190/50; delete/backup/partial/remove-source/daemon subset green. --- backup.c | 2 +- cleanup.c | 2 +- clientserver.c | 2 +- delete.c | 6 +- generator.c | 12 ++-- receiver.c | 6 +- rsync.c | 2 +- sender.c | 2 +- util1.c | 4 +- vfs/copy_file.c | 4 +- vfs/robust.c | 10 ++-- vfs/unlink.c | 143 ++++++++++++++---------------------------------- vfs/vfs.h | 8 +-- 13 files changed, 69 insertions(+), 134 deletions(-) diff --git a/backup.c b/backup.c index f3e3f7c76..307914dc1 100644 --- a/backup.c +++ b/backup.c @@ -251,7 +251,7 @@ static inline int link_or_rename(const char *from, const char *to, if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { /* If someone has hard-linked the file into the backup * dir, rename() might return success but do nothing! */ - robust_unlink(from); /* Just in case... */ + robust_unlink(from, VFS_OPERATOR_PATH); /* Just in case... */ } if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: RENAME %s successful.\n", from); diff --git a/cleanup.c b/cleanup.c index 7674baf32..c97b43579 100644 --- a/cleanup.c +++ b/cleanup.c @@ -198,7 +198,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line) switch_step++; if (cleanup_fname) - vfs_unlink_at(cleanup_fname); + vfs_unlink(VFS_AT_FDCWD, cleanup_fname, 0); if (exit_code) kill_all(SIGUSR1); if (cleanup_pid && cleanup_pid == getpid()) { diff --git a/clientserver.c b/clientserver.c index 2f92d44db..50ae97c96 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1627,7 +1627,7 @@ static void create_pid_file(void) } } #define PID_LSTAT(stp) vfs_lstat_atfd(pdfd, base, stp) -#define PID_UNLINK() vfs_unlink_atfd(pdfd, base, 0) +#define PID_UNLINK() vfs_unlink(pdfd, base, 0) #define PID_OPEN() vfs_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) #else #define PID_LSTAT(stp) vfs_lstat(base, stp) diff --git a/delete.c b/delete.c index 856a65d3a..c5dd5f41b 100644 --- a/delete.c +++ b/delete.c @@ -72,9 +72,9 @@ static int del_unlink(const char *fbuf) { const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); - if (dfd >= 0 && vfs_unlink_atfd(dfd, leaf, 0) == 0) + if (dfd >= 0 && vfs_unlink(dfd, leaf, 0) == 0) return 0; - return robust_unlink(fbuf); /* fall back (ETXTBSY retry, or not held) */ + return robust_unlink(fbuf, 0); /* fall back (ETXTBSY retry, or not held) */ } static inline int is_backup_file(char *fn) @@ -223,7 +223,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); what = "rmdir"; - ok = (dfd >= 0 ? vfs_unlink_atfd(dfd, leaf, AT_REMOVEDIR) : vfs_rmdir_at(fbuf)) == 0; + ok = (dfd >= 0 ? vfs_unlink(dfd, leaf, VFS_REMOVEDIR) : vfs_unlink(VFS_AT_FDCWD, fbuf, VFS_REMOVEDIR)) == 0; } else { if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) { what = "make_backup"; diff --git a/generator.c b/generator.c index be9ae3d72..06e8f752e 100644 --- a/generator.c +++ b/generator.c @@ -939,7 +939,7 @@ static int copy_altdest_file(const char *src, const char *dest, struct file_stru /* Try to clean up. copy_to's parent components are peer-named * and can be raced to a symlink, so resolve each with O_NOFOLLOW * via vfs_unlink_at() like the other generator-side unlinks. */ - vfs_unlink_at(copy_to); + vfs_unlink(VFS_AT_FDCWD, copy_to, 0); cleanup_disable(); return -1; } @@ -1114,7 +1114,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, if (find_exact_for_existing) { if (alt_dest_type == LINK_DEST && real_st.st_dev == sxp->st.st_dev && real_st.st_ino == sxp->st.st_ino) return -1; - if (vfs_unlink_at(fname) < 0 && errno != ENOENT) + if (vfs_unlink(VFS_AT_FDCWD, fname, 0) < 0 && errno != ENOENT) goto got_nothing_for_ya; } #ifdef SUPPORT_HARD_LINKS @@ -1466,9 +1466,9 @@ static int gen_entry_unlink(const char *path, struct file_struct *file) int dfd = vfs_cached_dirfd(path, file); if (dfd >= 0) { const char *slash = strrchr(path, '/'); - return vfs_unlink_atfd(dfd, slash ? slash + 1 : path, 0); + return vfs_unlink(dfd, slash ? slash + 1 : path, 0); } - return vfs_unlink_at(path); + return vfs_unlink(VFS_AT_FDCWD, path, 0); } /* opath and npath are both expected to live in the entry's directory (the @@ -2212,7 +2212,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, * through the exclude-aware ownership walk so a symlinked * partial-dir can't delete a file in an excluded subtree. */ vfs.operator_path_resolve = 1; - vfs_unlink_at(partialptr); + vfs_unlink(VFS_AT_FDCWD, partialptr, VFS_OPERATOR_PATH); vfs.operator_path_resolve = 0; handle_partial_dir(partialptr, PDIR_DELETE); } @@ -2310,7 +2310,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, vfs.operator_path_resolve = 0; goto pretend_missing; } - if (robust_unlink(backupptr) && errno != ENOENT) { + if (robust_unlink(backupptr, VFS_OPERATOR_PATH) && errno != ENOENT) { vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(backupptr)); diff --git a/receiver.c b/receiver.c index 2147ec55e..b1d129886 100644 --- a/receiver.c +++ b/receiver.c @@ -1291,7 +1291,7 @@ int recv_files(int f_in, int f_out, char *local_name) * exclude-aware ownership walk (a symlinked partial-dir * must not delete a file in an excluded subtree). */ vfs.operator_path_resolve = 1; - vfs_unlink_at(partialptr); + vfs_unlink(VFS_AT_FDCWD, partialptr, VFS_OPERATOR_PATH); vfs.operator_path_resolve = 0; } handle_partial_dir(partialptr, PDIR_DELETE); @@ -1302,7 +1302,7 @@ int recv_files(int f_in, int f_out, char *local_name) "Unable to create partial-dir for %s -- discarding %s.\n", local_name ? local_name : f_name(file, NULL), recv_ok ? "completed file" : "partial file"); - vfs_unlink_at(fnametmp); + vfs_unlink(VFS_AT_FDCWD, fnametmp, 0); recv_ok = -1; } else if (!finish_transfer(partialptr, fnametmp, fnamecmp, NULL, file, recv_ok, !partial_dir)) @@ -1313,7 +1313,7 @@ int recv_files(int f_in, int f_out, char *local_name) } else partialptr = NULL; } else if (!one_inplace) - vfs_unlink_at(fnametmp); + vfs_unlink(VFS_AT_FDCWD, fnametmp, 0); cleanup_disable(); diff --git a/rsync.c b/rsync.c index 1e6467236..6b2439522 100644 --- a/rsync.c +++ b/rsync.c @@ -918,7 +918,7 @@ int finish_transfer(const char *fname, const char *fnametmp, full_fname(fnametmp), fname); if (!partialptr || (ret == -2 && temp_copy_name) || robust_rename(fnametmp, partialptr, NULL, file->mode, file) < 0) - vfs_unlink_at(fnametmp); + vfs_unlink(VFS_AT_FDCWD, fnametmp, 0); return 0; } if (ret == 0) { diff --git a/sender.c b/sender.c index fd0c350ab..2b66f0834 100644 --- a/sender.c +++ b/sender.c @@ -418,7 +418,7 @@ void successful_send(int ndx) return; } - if (dfd >= 0 ? secure_remove_source_file(dfd, bname) < 0 : vfs_unlink(fname) < 0) { + if (dfd >= 0 ? secure_remove_source_file(dfd, bname) < 0 : vfs_unlink(VFS_AT_FDCWD, fname, VFS_ALLOW_SYMLINK) < 0) { failed_op = "remove"; failed: if (errno == ENOENT) diff --git a/util1.c b/util1.c index 6d3afaa65..97fcb4ab6 100644 --- a/util1.c +++ b/util1.c @@ -1085,7 +1085,7 @@ int handle_partial_dir(const char *fname, int create) STRUCT_STAT st; int statret = vfs_lstat_at(dir, &st); if (statret == 0 && !S_ISDIR(st.st_mode)) { - if (vfs_unlink_at(dir) < 0) { + if (vfs_unlink(VFS_AT_FDCWD, dir, VFS_OPERATOR_PATH) < 0) { vfs.operator_path_resolve = 0; *fn = '/'; return 0; @@ -1098,7 +1098,7 @@ int handle_partial_dir(const char *fname, int create) return 0; } } else - vfs_rmdir_at(dir); + vfs_unlink(VFS_AT_FDCWD, dir, VFS_REMOVEDIR); vfs.operator_path_resolve = 0; *fn = '/'; diff --git a/vfs/copy_file.c b/vfs/copy_file.c index d4585483a..7aa11c7a9 100644 --- a/vfs/copy_file.c +++ b/vfs/copy_file.c @@ -48,7 +48,7 @@ static int unlink_and_reopen(const char *dest, mode_t mode, int vfs_flags) { int ofd; - if (robust_unlink(dest) && errno != ENOENT) { + if (robust_unlink(dest, vfs_flags) && errno != ENOENT) { int save_errno = errno; rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(dest)); errno = save_errno; @@ -63,7 +63,7 @@ static int unlink_and_reopen(const char *dest, mode_t mode, int vfs_flags) /* Use vfs_open_at so the create/truncate goes through a secure * parent dirfd in the daemon-no-chroot deployment. Otherwise * an attacker could swap a parent component with a symlink in - * the window between robust_unlink (which uses vfs_unlink_at, + * the window between robust_unlink (which uses vfs_unlink, * already secure) and the create here, and redirect the new * file outside the module. */ if ((ofd = vfs_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode, vfs_flags)) < 0) { diff --git a/vfs/robust.c b/vfs/robust.c index 3c4c0b076..0b5972b4c 100644 --- a/vfs/robust.c +++ b/vfs/robust.c @@ -32,16 +32,16 @@ * --delete trying to remove old .rsyncNNN files, hence it renames it * each time. **/ -int robust_unlink(const char *fname) +int robust_unlink(const char *fname, int vfs_flags) { #ifndef ETXTBSY - return vfs_unlink_at(fname); + return vfs_unlink(VFS_AT_FDCWD, fname, vfs_flags); #else static int counter = 1; int rc, pos, start; char path[MAXPATHLEN]; - rc = vfs_unlink_at(fname); + rc = vfs_unlink(VFS_AT_FDCWD, fname, vfs_flags); if (rc == 0 || errno != ETXTBSY) return rc; @@ -111,7 +111,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, switch (errno) { #ifdef ETXTBSY case ETXTBSY: - if (robust_unlink(to) != 0) { + if (robust_unlink(to, 0) != 0) { errno = ETXTBSY; return -1; } @@ -126,7 +126,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, } if (copy_file(from, to, -1, mode, 0) != 0) return -2; - vfs_unlink_at(from); + vfs_unlink(VFS_AT_FDCWD, from, 0); return 1; default: return -1; diff --git a/vfs/unlink.c b/vfs/unlink.c index 96af614ab..3562a898b 100644 --- a/vfs/unlink.c +++ b/vfs/unlink.c @@ -16,42 +16,26 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -int vfs_unlink(const char *path) +/* Secure receiver-side resolve for an unlink/rmdir. unlink() resolves parent + * components, so a parent-symlink swap can delete an outside file under the + * daemon's authority -- defence is to resolve the parent securely and unlinkat() + * the leaf. unlink (not rmdir) honours the operator ownership walk; the rmdir + * path has no owner-walk branch (pre-existing -- matched the old vfs_rmdir_at). + * Falls through to a plain unlink()/rmdir() in non-daemon/sender, chrooted, + * no-parent and absolute-path cases. */ +static int vfs__unlink_secure(const char *path, int flags) { - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return unlink(path); -} - -/* - Symlink-race-safe variant of vfs_unlink() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. unlink() resolves - parent components, so a parent-symlink swap can delete an outside - file under the daemon's authority. Defence: open the parent of path - under vfs_resolve_open() and use unlinkat() (flags=0) against - that dirfd. - - Falls through to vfs_unlink() for the same dry-run / non-daemon / - chrooted / no-parent / absolute-path cases as the other wrappers. -*/ -int vfs_unlink_at(const char *path) -{ -#ifdef AT_FDCWD + int rmdir_op = flags & VFS_REMOVEDIR; +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; + const char *bname, *slash; + int dfd, ret, e, atflag = rmdir_op ? AT_REMOVEDIR : 0; size_t dlen; - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); - -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { + if (!rmdir_op && (flags & VFS_OPERATOR_PATH)) { if (vfs_symlink_optout_allowed()) return unlink(path); - dfd = vfs_owner_walk_parent(path, &bname, vfs.operator_path_resolve); + dfd = vfs_owner_walk_parent(path, &bname, 1); if (dfd < 0) return -1; ret = unlinkat(dfd, bname, 0); @@ -60,18 +44,12 @@ int vfs_unlink_at(const char *path) errno = e; return ret; } -#endif - - if (!vfs_relpath_active()) - return unlink(path); - - if (!path || !*path || *path == '/') - return unlink(path); + if (!vfs_relpath_active() || !*path || *path == '/') + return rmdir_op ? rmdir(path) : unlink(path); slash = strrchr(path, '/'); if (!slash) - return unlink(path); - + return rmdir_op ? rmdir(path) : unlink(path); dlen = slash - path; if (dlen >= sizeof dirpath) { errno = ENAMETOOLONG; @@ -84,84 +62,45 @@ int vfs_unlink_at(const char *path) dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; - - ret = unlinkat(dfd, bname, 0); + ret = unlinkat(dfd, bname, atflag); e = errno; close(dfd); errno = e; return ret; #else - return vfs_unlink(path); + return rmdir_op ? rmdir(path) : unlink(path); #endif } -int vfs_rmdir(const char *pathname) +/* Unified unlink/rmdir. dirfd == VFS_AT_FDCWD resolves `path`; a real held + * dirfd makes `path` a single component removed directly under it. flags: + * VFS_REMOVEDIR (rmdir/AT_REMOVEDIR instead of unlink), VFS_ALLOW_SYMLINK + * (trusted, plain), VFS_OPERATOR_PATH (operator path; unlink only), default 0 + * (secure receiver resolve). */ +int vfs_unlink(int dirfd, const char *path, int flags) { if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - return rmdir(pathname); -} + RETURN_ERROR_IF_NULL(path); -/* - Symlink-race-safe variant of vfs_rmdir(). See vfs_unlink_at() above; - same shape but with AT_REMOVEDIR set to require the target be a - directory. -*/ -int vfs_rmdir_at(const char *pathname) -{ + if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD - char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; - int dfd, ret, e; - size_t dlen; - - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(pathname); - - if (!vfs_relpath_active()) - return rmdir(pathname); - - if (!pathname || !*pathname || *pathname == '/') - return rmdir(pathname); - - slash = strrchr(pathname, '/'); - if (!slash) - return rmdir(pathname); - - dlen = slash - pathname; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; + if (!*path || strchr(path, '/') + || (path[0] == '.' && (path[1] == '\0' + || (path[1] == '.' && path[2] == '\0')))) { + errno = EINVAL; + return -1; + } + return unlinkat(dirfd, path, (flags & VFS_REMOVEDIR) ? AT_REMOVEDIR : 0); +#else + (void)dirfd; + errno = ENOSYS; return -1; +#endif } - memcpy(dirpath, pathname, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); - if (dfd < 0) - return -1; + if (flags & VFS_ALLOW_SYMLINK) + return (flags & VFS_REMOVEDIR) ? rmdir(path) : unlink(path); - ret = unlinkat(dfd, bname, AT_REMOVEDIR); - e = errno; - close(dfd); - errno = e; - return ret; -#else - return vfs_rmdir(pathname); -#endif -} - -int vfs_unlink_atfd(int dfd, const char *name, int flags) -{ -#ifdef AT_FDCWD - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - return unlinkat(dfd, name, flags); -#else - (void)dfd; (void)name; (void)flags; - errno = ENOSYS; - return -1; -#endif + return vfs__unlink_secure(path, flags); } diff --git a/vfs/vfs.h b/vfs/vfs.h index 2a7136e7a..512afb37d 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -125,11 +125,7 @@ int vfs_rename_at(const char *old_path, const char *new_path); int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name); /* unlink and rmdir (vfs/unlink.c). */ -int vfs_unlink(const char *path); -int vfs_unlink_at(const char *path); -int vfs_unlink_atfd(int dfd, const char *name, int flags); -int vfs_rmdir(const char *pathname); -int vfs_rmdir_at(const char *pathname); +int vfs_unlink(int dirfd, const char *path, int flags); /* open (vfs/open.c). */ int vfs_open(const char *pathname, int flags, mode_t mode); @@ -185,7 +181,7 @@ int vfs_SetFileTime(const char *path, time_t crtime); * argument (VFS_OPERATOR_PATH for operator-supplied paths, else 0). */ int vfs_make_path(char *fname, int mkp_flags, int vfs_flags); int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, int vfs_flags); -int robust_unlink(const char *fname); +int robust_unlink(const char *fname, int vfs_flags); int robust_rename(const char *from, const char *to, const char *partialptr, int mode, struct file_struct *file); int vfs_utimensat(const char *path, STRUCT_STAT *stp); From d037014ff4c96e2e0bfb75db8385beb9ea44a5f4 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 09:17:19 +1000 Subject: [PATCH 35/69] vfs: flag vfs_rename_at + retire three operator-path blocks Give vfs_rename_at an explicit vfs_flags argument (it keeps its two-parent form, per the kept-explicit scope for rename) so its ownership-walk branch reads VFS_OPERATOR_PATH instead of the vfs.operator_path_resolve global. Call sites: the backup link/rename (make_backup) and the in-place/partial rename (receiver) pass VFS_OPERATOR_PATH; the transfer renames (generator entry rename, finish_transfer) and robust_rename pass 0; robust_unlink's ETXTBSY rename-retry now forwards robust_unlink's own vfs_flags -- so robust_unlink is fully explicit (both its unlink and its rename retry). With unlink and rename both flagged, three operator_path_resolve set/clear blocks that wrapped only a single now-flagged op are removed: the receiver partial-dir rename and the two partial-dir unlinks (generator + receiver). The remaining seven blocks still wrap not-yet-flagged ops (link, stat/lstat, chmod/lchown via set_file_attrs, secure_basis_open) and stay for now. Full suite 190/50; rename/partial/backup/inplace/daemon subset green. --- backup.c | 2 +- generator.c | 4 +--- receiver.c | 7 ++----- rsync.c | 2 +- t_rename_secure.c | 4 ++-- vfs/rename.c | 8 ++++---- vfs/robust.c | 4 ++-- vfs/vfs.h | 2 +- 8 files changed, 14 insertions(+), 19 deletions(-) diff --git a/backup.c b/backup.c index 307914dc1..592d89757 100644 --- a/backup.c +++ b/backup.c @@ -247,7 +247,7 @@ static inline int link_or_rename(const char *from, const char *to, return 0; } #endif - if (vfs_rename_at(from, to) == 0) { + if (vfs_rename_at(from, to, VFS_OPERATOR_PATH) == 0) { if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { /* If someone has hard-linked the file into the backup * dir, rename() might return success but do nothing! */ diff --git a/generator.c b/generator.c index 06e8f752e..c1f356f51 100644 --- a/generator.c +++ b/generator.c @@ -1483,7 +1483,7 @@ static int gen_entry_rename(const char *opath, const char *npath, struct file_st const char *ns = strrchr(npath, '/'); return vfs_rename_atfd(odfd, os ? os + 1 : opath, ndfd, ns ? ns + 1 : npath); } - return vfs_rename_at(opath, npath); + return vfs_rename_at(opath, npath, 0); } #ifdef SUPPORT_XATTRS @@ -2211,9 +2211,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, /* The --partial-dir basis is an operator/peer path: unlink it * through the exclude-aware ownership walk so a symlinked * partial-dir can't delete a file in an excluded subtree. */ - vfs.operator_path_resolve = 1; vfs_unlink(VFS_AT_FDCWD, partialptr, VFS_OPERATOR_PATH); - vfs.operator_path_resolve = 0; handle_partial_dir(partialptr, PDIR_DELETE); } set_file_attrs(fname, file, &sx, NULL, maybe_ATTRS_REPORT | maybe_ATTRS_ACCURATE_TIME); diff --git a/receiver.c b/receiver.c index b1d129886..fd703fb72 100644 --- a/receiver.c +++ b/receiver.c @@ -687,9 +687,8 @@ static void handle_delayed_updates(char *local_name) * walk so a symlinked partial-dir can't move a file out of * an excluded subtree. */ int rret; - vfs.operator_path_resolve = 1; - rret = vfs_rename_at(partialptr, fname); - vfs.operator_path_resolve = 0; + /* operator-supplied --partial-dir: resolve via the ownership walk. */ + rret = vfs_rename_at(partialptr, fname, VFS_OPERATOR_PATH); if (rret < 0) { rsyserr(FERROR_XFER, errno, "rename failed for %s (from %s)", @@ -1290,9 +1289,7 @@ int recv_files(int f_in, int f_out, char *local_name) /* Unlink the consumed --partial-dir basis through the * exclude-aware ownership walk (a symlinked partial-dir * must not delete a file in an excluded subtree). */ - vfs.operator_path_resolve = 1; vfs_unlink(VFS_AT_FDCWD, partialptr, VFS_OPERATOR_PATH); - vfs.operator_path_resolve = 0; } handle_partial_dir(partialptr, PDIR_DELETE); } diff --git a/rsync.c b/rsync.c index 6b2439522..4805d6f5e 100644 --- a/rsync.c +++ b/rsync.c @@ -934,7 +934,7 @@ int finish_transfer(const char *fname, const char *fnametmp, ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME); if (temp_copy_name) { - if (vfs_rename_at(fnametmp, fname) < 0) { + if (vfs_rename_at(fnametmp, fname, 0) < 0) { rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\"", full_fname(fnametmp), fname); return 0; diff --git a/t_rename_secure.c b/t_rename_secure.c index 51bde75e1..ebadcdd28 100644 --- a/t_rename_secure.c +++ b/t_rename_secure.c @@ -37,7 +37,7 @@ static int vulnerable_mixed_rename_at(const char *old_path, const char *new_path if (!old_slash || !new_slash) return vfs_rename(old_path, new_path); - return vfs_rename_at(old_path, new_path); + return vfs_rename_at(old_path, new_path, 0); } #endif @@ -64,7 +64,7 @@ static void check_rename(const char *label, const char *old_path, int saved_errno; errno = 0; - rc = vfs_rename_at(old_path, new_path); + rc = vfs_rename_at(old_path, new_path, 0); saved_errno = errno; got_ok = rc == 0; diff --git a/vfs/rename.c b/vfs/rename.c index a6b519321..ba7c6cd0f 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -39,7 +39,7 @@ int vfs_rename(const char *old_path, const char *new_path) Falls through to vfs_rename() in dry-run, non-daemon, chrooted and absolute-path cases, identical to the other do_*_at() wrappers. */ -int vfs_rename_at(const char *old_path, const char *new_path) +int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) { #ifdef AT_FDCWD char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; @@ -63,13 +63,13 @@ int vfs_rename_at(const char *old_path, const char *new_path) /* Operator-supplied path (e.g. a --backup-dir destination or a --temp-dir * source): resolve each side's parent via the ownership walk (follow * uid0/euid symlinks, refuse others; absolute and relative alike). */ - if (vfs.operator_path_resolve) { + if (vfs_flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) return vfs_rename(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, vfs.operator_path_resolve); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); if (old_dfd < 0) return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, vfs.operator_path_resolve); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); if (new_dfd < 0) { e = errno; close(old_dfd); diff --git a/vfs/robust.c b/vfs/robust.c index 0b5972b4c..a955b1ec6 100644 --- a/vfs/robust.c +++ b/vfs/robust.c @@ -71,7 +71,7 @@ int robust_unlink(const char *fname, int vfs_flags) } /* maybe we should return rename()'s exit status? Nah. */ - if (vfs_rename_at(fname, path) != 0) { + if (vfs_rename_at(fname, path, vfs_flags) != 0) { errno = ETXTBSY; return -1; } @@ -104,7 +104,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, const char *ns = strrchr(to, '/'); rr = vfs_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); } else - rr = vfs_rename_at(from, to); + rr = vfs_rename_at(from, to, 0); if (rr == 0) return 0; diff --git a/vfs/vfs.h b/vfs/vfs.h index 512afb37d..b2614f8a3 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -121,7 +121,7 @@ int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st); /* rename (vfs/rename.c). */ int vfs_rename(const char *old_path, const char *new_path); -int vfs_rename_at(const char *old_path, const char *new_path); +int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags); int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name); /* unlink and rmdir (vfs/unlink.c). */ From c89283a500738483c8a633b8e329a2023ff7e0d0 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 09:29:07 +1000 Subject: [PATCH 36/69] vfs: fix stale comment in unlink_and_reopen (robust_unlink now flagged) The comment predated flagging robust_unlink; vfs_flags now reaches both the robust_unlink and the create. (codex review nit.) --- vfs/copy_file.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vfs/copy_file.c b/vfs/copy_file.c index 7aa11c7a9..107072712 100644 --- a/vfs/copy_file.c +++ b/vfs/copy_file.c @@ -42,8 +42,7 @@ static int safe_read(int desc, char *ptr, size_t len) /* Remove existing file @dest and reopen, creating a new file with @mode. * vfs_flags carries the resolution policy (VFS_OPERATOR_PATH for an operator - * dest) to the create; the robust_unlink still reads it from the (transitional) - * operator-path global. */ + * dest) to both the robust_unlink and the create. */ static int unlink_and_reopen(const char *dest, mode_t mode, int vfs_flags) { int ofd; From 5c97ad3403fb9d2dae1167e6efb971c4b418eb8b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 09:47:43 +1000 Subject: [PATCH 37/69] vfs: flag vfs_open_owner_walk + secure_basis_open; retire two more blocks Give vfs_open_owner_walk an explicit is_operator argument so its module-root confinement (abspath_excluded_by_module) reads the policy from the caller instead of the vfs.operator_path_resolve global -- the last owner-walk function still reading it. secure_basis_open gains an is_operator parameter threaded to it; its gate (owner-walk vs strict resolve) now branches on that param too. Faithful conversion (no behaviour change): every direct caller passes the value the global held at that site -- config/log/motd/early-input/files-from/ batch/connection/exclude/authenticate/params, change_dir's daemon dest-chdir, and vfs_secure_mkstemp's --temp-dir all pass 0; secure_basis_open passes VFS_OPERATOR_PATH only for the operator cases (a --partial-dir basis, fnamecmp_type == FNAMECMP_PARTIAL_DIR; and one_inplace partial-dir staging). This preserves the existing --temp-dir behaviour (unconfined) by deliberate choice; hardening that is a separate decision. The receiver partial-dir-basis and one_inplace operator_path_resolve blocks are removed (receiver.c is now free of the global). Set-sites: 7 -> 5 (backup make_backup, generator 1071/2085/2130, util1 handle_partial_dir). The operator-path-partial-dir-daemon test caught a real regression mid-change (secure_basis_open's gate still read the global after its set/clear block was removed, dropping the confinement); fixed by gating on is_operator. Full suite 190/50. --- authenticate.c | 4 ++-- batch.c | 6 +++--- clientserver.c | 4 ++-- connection.c | 2 +- exclude.c | 2 +- log.c | 2 +- options.c | 2 +- params.c | 2 +- receiver.c | 35 ++++++++++++++--------------------- util1.c | 6 +++--- vfs/mkdir.c | 2 +- vfs/owner_walk.c | 4 ++-- vfs/vfs.h | 2 +- 13 files changed, 33 insertions(+), 40 deletions(-) diff --git a/authenticate.c b/authenticate.c index 7352c9acd..3e489d6c4 100644 --- a/authenticate.c +++ b/authenticate.c @@ -156,7 +156,7 @@ static const char *check_secret(int module, const char *user, const char *group, if (!fname || !*fname) return "no secrets file"; { - int fd = vfs_open_owner_walk(fname, O_RDONLY, 0); + int fd = vfs_open_owner_walk(fname, O_RDONLY, 0, 0); if (fd < 0) return "no secrets file"; fh = fdopen(fd, "r"); @@ -242,7 +242,7 @@ static const char *getpassf(const char *filename) * (e.g. shadow hashes) to a malicious daemon; the vfs_stat() * other-access check runs on the target mode and passes 0640 * root:shadow. Refuse symlinks not owned by uid 0 or our euid. */ - if ((fd = vfs_open_owner_walk(filename, O_RDONLY, 0)) < 0) { + if ((fd = vfs_open_owner_walk(filename, O_RDONLY, 0, 0)) < 0) { rsyserr(FERROR, errno, "could not open password file %s", filename); exit_cleanup(RERR_SYNTAX); } diff --git a/batch.c b/batch.c index 1ac5c0947..e47f9b813 100644 --- a/batch.c +++ b/batch.c @@ -251,7 +251,7 @@ void open_batch_files(void) stringjoin(filename, sizeof filename, batch_name, ".sh", NULL); - batch_sh_fd = vfs_open_owner_walk(filename, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR | S_IXUSR); + batch_sh_fd = vfs_open_owner_walk(filename, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR | S_IXUSR, 0); if (batch_sh_fd < 0) { rsyserr(FERROR, errno, "Batch file %s open error", full_fname(filename)); exit_cleanup(RERR_FILESELECT); @@ -260,11 +260,11 @@ void open_batch_files(void) /* O_BINARY: the batch stream is binary protocol data; without it * Cygwin et al apply CRLF translation and corrupt it. Unlike * vfs_open(), vfs_open_owner_walk passes flags verbatim. */ - batch_fd = vfs_open_owner_walk(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR); + batch_fd = vfs_open_owner_walk(batch_name, O_WRONLY | O_CREAT | O_TRUNC | O_BINARY, S_IRUSR | S_IWUSR, 0); } else if (strcmp(batch_name, "-") == 0) batch_fd = STDIN_FILENO; else - batch_fd = vfs_open_owner_walk(batch_name, O_RDONLY | O_BINARY, S_IRUSR | S_IWUSR); + batch_fd = vfs_open_owner_walk(batch_name, O_RDONLY | O_BINARY, S_IRUSR | S_IWUSR, 0); if (batch_fd < 0) { rsyserr(FERROR, errno, "Batch file %s open error", full_fname(batch_name)); diff --git a/clientserver.c b/clientserver.c index 50ae97c96..aab944068 100644 --- a/clientserver.c +++ b/clientserver.c @@ -185,7 +185,7 @@ static int exchange_protocols(int f_in, int f_out, char *buf, size_t bufsiz, int /* 'motd file = PATH': motd content is sent to every client, so * a planted symlink would leak the target's bytes. Refuse * symlinks not owned by uid 0 or our euid. */ - int motd_fd = vfs_open_owner_walk(motd, O_RDONLY, 0); + int motd_fd = vfs_open_owner_walk(motd, O_RDONLY, 0, 0); FILE *f = motd_fd >= 0 ? fdopen(motd_fd, "r") : NULL; if (!f && motd_fd >= 0) close(motd_fd); while (f && !feof(f)) { @@ -300,7 +300,7 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char STRUCT_STAT st; /* --early-input-file=PATH: refuse symlinks not owned by uid 0 or * our euid anywhere in the path. */ - int ei_fd = vfs_open_owner_walk(early_input_file, O_RDONLY, 0); + int ei_fd = vfs_open_owner_walk(early_input_file, O_RDONLY, 0, 0); FILE *f = ei_fd >= 0 ? fdopen(ei_fd, "rb") : NULL; if (!f && ei_fd >= 0) close(ei_fd); if (!f || vfs_fstat(fileno(f), &st) < 0) { diff --git a/connection.c b/connection.c index 1d212628c..85320c5af 100644 --- a/connection.c +++ b/connection.c @@ -32,7 +32,7 @@ int claim_connection(char *fname, int max_connections) /* 'lock file = PATH': refuse symlinks not owned by uid 0 or our euid so * a planted parent can't redirect the root daemon's O_CREAT open. */ - if ((fd = vfs_open_owner_walk(fname, O_RDWR|O_CREAT, 0600)) < 0) + if ((fd = vfs_open_owner_walk(fname, O_RDWR|O_CREAT, 0600, 0)) < 0) return 0; /* Find a free spot. */ diff --git a/exclude.c b/exclude.c index d7b6aa1ba..3f3db4911 100644 --- a/exclude.c +++ b/exclude.c @@ -1663,7 +1663,7 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ open_path = line; } else open_path = fname; - fd = vfs_open_owner_walk(open_path, O_RDONLY, 0); + fd = vfs_open_owner_walk(open_path, O_RDONLY, 0, 0); if (fd < 0) fp = NULL; else if (!(fp = fdopen(fd, "rb"))) diff --git a/log.c b/log.c index 62fdbf954..cec44ebd1 100644 --- a/log.c +++ b/log.c @@ -166,7 +166,7 @@ static void logfile_open(void) * into e.g. /root/.ssh/authorized_keys. Refuse symlinks not owned by * uid 0 or our euid. */ int fd = vfs_open_owner_walk(logfile_name, - O_WRONLY | O_APPEND | O_CREAT, 0644); + O_WRONLY | O_APPEND | O_CREAT, 0644, 0); logfile_fp = fd >= 0 ? fdopen(fd, "a") : NULL; if (!logfile_fp && fd >= 0) close(fd); diff --git a/options.c b/options.c index b6a55ae23..dda9dcdd0 100644 --- a/options.c +++ b/options.c @@ -2643,7 +2643,7 @@ int parse_arguments(int *argc_p, const char ***argv_p) /* Operator-supplied path that may transit attacker-writable * parents; refuse symlinks not owned by uid 0 or our euid, * as for --exclude-from/--include-from/--filter in exclude.c. */ - filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0); + filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0, 0); if (filesfrom_fd < 0) { snprintf(err_buf, sizeof err_buf, "failed to open files-from file %s: %s\n", diff --git a/params.c b/params.c index df3bbee3f..a28d23ecc 100644 --- a/params.c +++ b/params.c @@ -583,7 +583,7 @@ static FILE *OpenConfFile( char *FileName ) /* rsyncd.conf path (--config or default): a planted symlink could redirect * the daemon's config read. Refuse symlinks not owned by uid 0 or euid. */ { - int cfg_fd = vfs_open_owner_walk( FileName, O_RDONLY, 0 ); + int cfg_fd = vfs_open_owner_walk( FileName, O_RDONLY, 0 , 0); OpenedFile = cfg_fd >= 0 ? fdopen( cfg_fd, "r" ) : NULL; if( !OpenedFile && cfg_fd >= 0 ) close( cfg_fd ); diff --git a/receiver.c b/receiver.c index fd703fb72..4377a37bb 100644 --- a/receiver.c +++ b/receiver.c @@ -98,18 +98,18 @@ static int updating_basis_or_equiv; * (trusted) and leaf and confine just the leaf -- exactly how secure_relative_ * open already trusts an absolute basedir while O_NOFOLLOW-confining the leaf. * Anything else is a straight pass-through that preserves the strict contract. */ -static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode) +static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode, int is_operator) { extern int am_daemon, am_chrooted; extern unsigned int module_dirlen; - /* A peer-supplied --partial-dir basis/staging path (vfs.operator_path_resolve set - * by recv_files) may be absolute (module_dir-prefixed on a non-chroot daemon) - * and traverse a symlink the vfs_resolve_open path can't confine: resolve - * it with the ownership walk, which follows a uid0/euid-owned symlink but - * refuses a foreign one AND (via abspath_excluded_by_module) refuses a target - * the module's exclude hides -- closing the partial-dir exclude bypass. */ - if (vfs.operator_path_resolve) { + /* A peer-supplied --partial-dir basis/staging path (is_operator, set by the + * recv_files caller) may be absolute (module_dir-prefixed on a non-chroot + * daemon) and traverse a symlink the vfs_resolve_open path can't confine: + * resolve it with the ownership walk, which follows a uid0/euid-owned symlink + * but refuses a foreign one AND (via abspath_excluded_by_module) refuses a + * target the module's exclude hides -- closing the partial-dir exclude bypass. */ + if (is_operator) { char fullpath[MAXPATHLEN]; const char *p = relpath; if (basedir) { @@ -119,7 +119,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags } p = fullpath; } - return vfs_open_owner_walk(p, flags, mode); + return vfs_open_owner_walk(p, flags, mode, is_operator); } /* The confined resolver is needed for the sanitizing daemon @@ -1057,10 +1057,8 @@ int recv_files(int f_in, int f_out, char *local_name) * the exclude-aware ownership walk so a symlinked partial-dir * can't read (and feed back as delta) a file in an excluded * subtree. */ - if (fnamecmp_type == FNAMECMP_PARTIAL_DIR) - vfs.operator_path_resolve = 1; - fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); - vfs.operator_path_resolve = 0; + fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0, + fnamecmp_type == FNAMECMP_PARTIAL_DIR ? VFS_OPERATOR_PATH : 0); } } if (fnamecmp_type == FNAMECMP_PARTIAL_DIR && fd1 == -1) { @@ -1091,10 +1089,7 @@ int recv_files(int f_in, int f_out, char *local_name) basedir = basis_dir[0]; fnamecmp = fname; fnamecmp_type = FNAMECMP_BASIS_DIR_LOW; - if (!am_daemon) - operator_path_resolve = 1; - fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); - operator_path_resolve = 0; + fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0, 0); } } @@ -1174,13 +1169,11 @@ int recv_files(int f_in, int f_out, char *local_name) /* one_inplace stages into the operator/peer --partial-dir path: * resolve it with the ownership walk (exclude-aware) so it can't be * redirected through a symlink into an excluded subtree. */ - if (one_inplace) - vfs.operator_path_resolve = 1; if (vfs_relpath_active()) - fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); + fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600, + one_inplace ? VFS_OPERATOR_PATH : 0); else fd2 = vfs_open(fnametmp, O_WRONLY|O_CREAT, 0600); - vfs.operator_path_resolve = 0; #ifdef linux if (fd2 == -1 && errno == EACCES) { /* Maybe the error was due to protected_regular setting? */ diff --git a/util1.c b/util1.c index 97fcb4ab6..50a18584f 100644 --- a/util1.c +++ b/util1.c @@ -817,7 +817,7 @@ int change_dir(const char *dir, int set_path_only) * non-daemon receiver can opt back into the legacy plain chdir with * --insecure-links. */ if (am_daemon && !am_chrooted) { - int dfd = vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0); + int dfd = vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0, 0); if (dfd < 0) return 0; if (fchdir(dfd) != 0) { @@ -848,7 +848,7 @@ int change_dir(const char *dir, int set_path_only) * another uid. A real dir is opened directly. This closes the * destination chdir TOCTOU; --insecure-links keeps the plain * chdir for an operator whose dest is a foreign-owned symlink. */ - dfd = vfs_open_owner_walk(nf, O_RDONLY | O_DIRECTORY, 0); + dfd = vfs_open_owner_walk(nf, O_RDONLY | O_DIRECTORY, 0, 0); if (dfd < 0) return 0; if (fchdir(dfd) != 0) { @@ -929,7 +929,7 @@ int change_dir(const char *dir, int set_path_only) * relative-dest chdir TOCTOU while still following the operator's * own symlinks. --insecure-links keeps the plain chdir. */ int dfd = vfs_open_owner_walk(vfs.curr_dir, - O_RDONLY | O_DIRECTORY, 0); + O_RDONLY | O_DIRECTORY, 0, 0); if (dfd < 0) chdir_failed = 1; else { diff --git a/vfs/mkdir.c b/vfs/mkdir.c index 2d655cfcd..8d7e0076b 100644 --- a/vfs/mkdir.c +++ b/vfs/mkdir.c @@ -298,7 +298,7 @@ int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path) dir = dirbuf; } dirfd = operator_path - ? vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0) + ? vfs_open_owner_walk(dir, O_RDONLY | O_DIRECTORY, 0, 0) : vfs_resolve_open(dir, ".", O_RDONLY | O_DIRECTORY, 0); if (dirfd < 0) return -1; diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 9aa2004f8..91e4256d1 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -292,9 +292,9 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz #endif } -int vfs_open_owner_walk(const char *path, int flags, mode_t mode) +int vfs_open_owner_walk(const char *path, int flags, mode_t mode, int is_operator) { - return ona_open(path, flags, mode, NULL, 0, vfs.operator_path_resolve); + return ona_open(path, flags, mode, NULL, 0, is_operator); } /* When set, the do_*_at() wrappers resolve their path as an OPERATOR-supplied diff --git a/vfs/vfs.h b/vfs/vfs.h index b2614f8a3..3370b3762 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -100,7 +100,7 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode); /* Operator-supplied-path resolution by ownership (vfs/owner_walk.c). */ -int vfs_open_owner_walk(const char *path, int flags, mode_t mode); +int vfs_open_owner_walk(const char *path, int flags, mode_t mode, int is_operator); int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator); /* Held ancestor-dirfd cache for directory traversal (vfs/dircache.c). */ From 4a0f20f657562d358b5206dc447771ec73aa75ad Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 11:16:38 +1000 Subject: [PATCH 38/69] vfs: flag vfs_link_at + thread hard_link_one; retire the hard-link block Give vfs_link_at an explicit vfs_flags argument (kept two-parent form) so its ownership-walk branch reads VFS_OPERATOR_PATH instead of the global. hard_link_one() gains a vfs_flags argument forwarded to vfs_link_at, so the generator's alt-dest hard-link no longer sets vfs.operator_path_resolve around the call: it passes (!am_daemon ? VFS_OPERATOR_PATH : 0), matching the prior op = !am_daemon gating (a non-daemon --link-dest uses the ownership walk; a daemon keeps the stronger module-anchored vfs_relpath_active path). That operator_path_resolve block is removed. Other call sites: the backup link (make_backup link_or_rename) passes VFS_OPERATOR_PATH; the try_dests alt-dest link and the hlink finish-up hard_link_one pass 0 (transfer context, as the global was there). Set-sites: 5 -> 4 (backup make_backup, generator 2085/2130, util1 handle_partial_dir -- all wrapping the not-yet-flagged metadata layer set_file_attrs/x_stat/x_lstat). Full suite 190/50; hardlink/link-dest/ backup/daemon subset green. --- backup.c | 2 +- generator.c | 12 ++++-------- hlink.c | 4 ++-- vfs/link.c | 8 ++++---- vfs/vfs.h | 2 +- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/backup.c b/backup.c index 592d89757..fadfc2ec1 100644 --- a/backup.c +++ b/backup.c @@ -237,7 +237,7 @@ static inline int link_or_rename(const char *from, const char *to, if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode)) return 0; /* Use copy code. */ #endif - if (vfs_link_at(from, to) == 0) { + if (vfs_link_at(from, to, VFS_OPERATOR_PATH) == 0) { if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: HLINK %s successful.\n", from); return 2; diff --git a/generator.c b/generator.c index c1f356f51..e639dfe71 100644 --- a/generator.c +++ b/generator.c @@ -1126,12 +1126,8 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, * keeps its stronger module-anchored confinement (vfs_link_at's * vfs_relpath_active path) -- the ownership walk would follow an * operator-owned symlink out of the module. */ - int hlok, op = !am_daemon; - if (op) - vfs.operator_path_resolve = 1; - hlok = hard_link_one(file, fname, cmpbuf, 1); - if (op) - vfs.operator_path_resolve = 0; + int hlok = hard_link_one(file, fname, cmpbuf, 1, + !am_daemon ? VFS_OPERATOR_PATH : 0); if (!hlok) goto try_a_copy; if (atimes_ndx) @@ -1265,7 +1261,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, && !IS_SPECIAL(file->mode) && !IS_DEVICE(file->mode) #endif && !S_ISDIR(file->mode)) { - if (vfs_link_at(cmpbuf, fname) < 0) { + if (vfs_link_at(cmpbuf, fname, 0) < 0) { rsyserr(FERROR_XFER, errno, "failed to hard-link %s with %s", cmpbuf, fname); @@ -2469,7 +2465,7 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const #endif } else if (hlnk) { #ifdef SUPPORT_HARD_LINKS - if (!hard_link_one(file, create_name, hlnk, 0)) + if (!hard_link_one(file, create_name, hlnk, 0, 0)) return 0; #else return 0; diff --git a/hlink.c b/hlink.c index e0cb6a2f4..8646fb44b 100644 --- a/hlink.c +++ b/hlink.c @@ -473,9 +473,9 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, } int hard_link_one(struct file_struct *file, const char *fname, - const char *oldname, int terse) + const char *oldname, int terse, int vfs_flags) { - if (vfs_link_at(oldname, fname) < 0) { + if (vfs_link_at(oldname, fname, vfs_flags) < 0) { enum logcode code; if (terse) { if (!INFO_GTE(NAME, 1)) diff --git a/vfs/link.c b/vfs/link.c index 3b8d0c9eb..c76c268a1 100644 --- a/vfs/link.c +++ b/vfs/link.c @@ -44,7 +44,7 @@ int vfs_link(const char *old_path, const char *new_path) symbolic-link old_path). Only available on systems with linkat(); pre-AT_FDCWD systems fall through to vfs_link(). */ -int vfs_link_at(const char *old_path, const char *new_path) +int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) { #if defined AT_FDCWD && defined HAVE_LINKAT char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; @@ -67,13 +67,13 @@ int vfs_link_at(const char *old_path, const char *new_path) #if defined O_NOFOLLOW && defined O_DIRECTORY /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ - if (vfs.operator_path_resolve) { + if (vfs_flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) return vfs_link(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, vfs.operator_path_resolve); + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); if (old_dfd < 0) return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, vfs.operator_path_resolve); + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); if (new_dfd < 0) { e = errno; close(old_dfd); diff --git a/vfs/vfs.h b/vfs/vfs.h index 3370b3762..a8fa94d2c 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -149,7 +149,7 @@ ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz); /* hard links (vfs/link.c). */ int vfs_link(const char *old_path, const char *new_path); -int vfs_link_at(const char *old_path, const char *new_path); +int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags); int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags); /* mkdir / mkstemp and the trim_trailing_slashes path helper (vfs/mkdir.c). */ From 871d1974f1b300b02807e59e8b2b91f95f9b017b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 11:30:43 +1000 Subject: [PATCH 39/69] vfs: thread the operator flag through stat/lstat + x_stat/x_lstat vfs_stat_at / vfs_lstat_at (and their shared do_xstat_at helper) gain a vfs_flags argument so the ownership-walk branch reads VFS_OPERATOR_PATH instead of vfs.operator_path_resolve -- this was the last primitive still reading the global. The metadata wrappers x_stat()/x_lstat() (which stay in xattrs.c, above the VFS) gain a vfs_flags argument forwarded to the primitives. Call sites classified: every backup-dir stat (validate_backup_dir, copy_valid_path's x_stat, make_backup_inner's x_lstat + bak lstat) and the partial-dir lstat (handle_partial_dir) pass VFS_OPERATOR_PATH; the transfer enumeration (flist link_stat + friends), the in-place dir stat (generator), and the set_stat_xattr lstat pass 0. generator's non-daemon --link-dest basis lookup passes 0 too (is_operator only gates the daemon module confinement, a no-op there). With this, NOTHING reads vfs.operator_path_resolve any more -- it is now write-only (set by four now-dead blocks, removed next). Full suite 190/50; backup/partial/link-dest/xattr/daemon subset green. --- backup.c | 8 ++++---- flist.c | 14 +++++++------- generator.c | 6 ++++-- util1.c | 2 +- vfs/stat.c | 16 ++++++++-------- vfs/vfs.h | 4 ++-- xattrs.c | 13 +++++++------ 7 files changed, 33 insertions(+), 30 deletions(-) diff --git a/backup.c b/backup.c index fadfc2ec1..45c542713 100644 --- a/backup.c +++ b/backup.c @@ -65,7 +65,7 @@ static int validate_backup_dir(void) { STRUCT_STAT st; - if (vfs_lstat_at(backup_dir_buf, &st) < 0) { + if (vfs_lstat_at(backup_dir_buf, &st, VFS_OPERATOR_PATH) < 0) { if (errno == ENOENT) return 0; rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf); @@ -140,7 +140,7 @@ static BOOL copy_valid_path(const char *fname) /* Try to transfer the directory settings of the actual dir * that the files are coming from. */ - if (x_stat(rel, &sx.st, NULL) < 0) + if (x_stat(rel, &sx.st, NULL, VFS_OPERATOR_PATH) < 0) rsyserr(FERROR, errno, "backup stat %s failed", full_fname(rel)); else { struct file_struct *file; @@ -273,7 +273,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) init_stat_x(&sx); /* Return success if no file to keep. */ - if (x_lstat(fname, &sx.st, NULL) < 0) + if (x_lstat(fname, &sx.st, NULL, VFS_OPERATOR_PATH) < 0) return 3; if (!(buf = get_backup_name(fname))) @@ -318,7 +318,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) goto success; if (errno == EEXIST || errno == EISDIR) { STRUCT_STAT bakst; - if (vfs_lstat_at(buf, &bakst) == 0) { + if (vfs_lstat_at(buf, &bakst, VFS_OPERATOR_PATH) == 0) { int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE; if (delete_item(buf, bakst.st_mode, flags) != 0) return 0; diff --git a/flist.c b/flist.c index 55f3eebda..71bbfe412 100644 --- a/flist.c +++ b/flist.c @@ -267,7 +267,7 @@ static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf) rprintf(FINFO,"copying unsafe symlink \"%s\" -> \"%s\"\n", path, linkbuf); } - return x_stat(path, stp, NULL); + return x_stat(path, stp, NULL, 0); } if (munge_symlinks && am_sender && llen > SYMLINK_PREFIX_LEN && strncmp(linkbuf, SYMLINK_PREFIX, SYMLINK_PREFIX_LEN) == 0) { @@ -277,7 +277,7 @@ static int readlink_stat(const char *path, STRUCT_STAT *stp, char *linkbuf) } return 0; #else - return x_stat(path, stp, NULL); + return x_stat(path, stp, NULL, 0); #endif } @@ -285,17 +285,17 @@ int link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlinks) { #ifdef SUPPORT_LINKS if (copy_links) - return x_stat(path, stp, NULL); - if (x_lstat(path, stp, NULL) < 0) + return x_stat(path, stp, NULL, 0); + if (x_lstat(path, stp, NULL, 0) < 0) return -1; if (follow_dirlinks && S_ISLNK(stp->st_mode)) { STRUCT_STAT st; - if (x_stat(path, &st, NULL) == 0 && S_ISDIR(st.st_mode)) + if (x_stat(path, &st, NULL, 0) == 0 && S_ISDIR(st.st_mode)) *stp = st; } return 0; #else - return x_stat(path, stp, NULL); + return x_stat(path, stp, NULL, 0); #endif } @@ -1447,7 +1447,7 @@ struct file_struct *make_file(const char *fname, struct file_list *flist, * options was specified, so there's no need for the * extra lstat() if one of these options isn't on. */ if ((copy_links || copy_unsafe_links || copy_dirlinks) - && x_lstat(thisname, &st, NULL) == 0 + && x_lstat(thisname, &st, NULL, 0) == 0 && S_ISLNK(st.st_mode)) { io_error |= IOERR_GENERAL; rprintf(FERROR_XFER, "symlink has no referent: %s\n", diff --git a/generator.c b/generator.c index e639dfe71..e38bbe9f0 100644 --- a/generator.c +++ b/generator.c @@ -977,7 +977,9 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) * the plain path (a lower-severity, non-root basis lookup). */ if (!am_daemon && am_root >= 0 && !vfs_symlink_optout_allowed()) { const char *leaf; - int dfd = vfs_owner_walk_parent(path, &leaf, vfs.operator_path_resolve); + /* non-daemon path: is_operator only gates the daemon module-confinement + * (a no-op here), so the ownership walk is identical either way. */ + int dfd = vfs_owner_walk_parent(path, &leaf, 0); int r, e; if (dfd < 0) return -1; @@ -1685,7 +1687,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } } if (relative_paths && !implied_dirs && file->mode != 0 - && vfs_stat_at(dn, &sx.st) < 0) { + && vfs_stat_at(dn, &sx.st, 0) < 0) { if (dry_run) goto parent_is_dry_missing; if (vfs_make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH, 0) < 0) { diff --git a/util1.c b/util1.c index 50a18584f..48ca19235 100644 --- a/util1.c +++ b/util1.c @@ -1083,7 +1083,7 @@ int handle_partial_dir(const char *fname, int create) vfs.operator_path_resolve = 1; if (create) { STRUCT_STAT st; - int statret = vfs_lstat_at(dir, &st); + int statret = vfs_lstat_at(dir, &st, VFS_OPERATOR_PATH); if (statret == 0 && !S_ISDIR(st.st_mode)) { if (vfs_unlink(VFS_AT_FDCWD, dir, VFS_OPERATOR_PATH) < 0) { vfs.operator_path_resolve = 0; diff --git a/vfs/stat.c b/vfs/stat.c index 0089080d7..2ee591aba 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -53,7 +53,7 @@ int vfs_lstat(const char *path, STRUCT_STAT *st) fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against that dirfd. Same fall-through gating as the other wrappers. */ -static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) +static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *), int vfs_flags) { #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; @@ -63,10 +63,10 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa size_t dlen; #if defined O_NOFOLLOW && defined O_DIRECTORY - if (vfs.operator_path_resolve) { + if (vfs_flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) return fallback(path, st); - dfd = vfs_owner_walk_parent(path, &bname, vfs.operator_path_resolve); + dfd = vfs_owner_walk_parent(path, &bname, 1); if (dfd < 0) return -1; ret = fstatat(dfd, bname, st, at_flags); @@ -110,17 +110,17 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa #endif } -int vfs_stat_at(const char *path, STRUCT_STAT *st) +int vfs_stat_at(const char *path, STRUCT_STAT *st, int vfs_flags) { - return do_xstat_at(path, st, 0, vfs_stat); + return do_xstat_at(path, st, 0, vfs_stat, vfs_flags); } -int vfs_lstat_at(const char *path, STRUCT_STAT *st) +int vfs_lstat_at(const char *path, STRUCT_STAT *st, int vfs_flags) { #ifdef SUPPORT_LINKS - return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs_lstat); + return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs_lstat, vfs_flags); #else - return do_xstat_at(path, st, 0, vfs_stat); + return do_xstat_at(path, st, 0, vfs_stat, vfs_flags); #endif } diff --git a/vfs/vfs.h b/vfs/vfs.h index a8fa94d2c..30ea3e8d1 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -114,8 +114,8 @@ void vfs_dircache_reset(void); int vfs_stat(const char *path, STRUCT_STAT *st); int vfs_lstat(const char *path, STRUCT_STAT *st); int vfs_fstat(int fd, STRUCT_STAT *st); -int vfs_stat_at(const char *path, STRUCT_STAT *st); -int vfs_lstat_at(const char *path, STRUCT_STAT *st); +int vfs_stat_at(const char *path, STRUCT_STAT *st, int vfs_flags); +int vfs_lstat_at(const char *path, STRUCT_STAT *st, int vfs_flags); int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st); int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st); diff --git a/xattrs.c b/xattrs.c index 0c7cd97a5..42613f417 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1295,7 +1295,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode, xst = fst; /* keep xst fully defined; st_mode=0 means "no stat xattr" */ xst.st_mode = 0; } - } else if (x_lstat(fname, &fst, &xst) < 0) { + } else if (x_lstat(fname, &fst, &xst, 0) < 0) { rsyserr(FERROR_XFER, errno, "failed to re-stat %s", full_fname(fname)); return -1; @@ -1356,22 +1356,23 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode, return 0; } -int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) +int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst, int vfs_flags) { /* Use the *_at variants so that on a daemon-no-chroot deployment * the metadata read goes through a secure parent dirfd instead * of bare path resolution. The *_at wrappers fall through to * plain vfs_stat outside the daemon-no-chroot context, so this - * change is transparent for non-daemon use. */ - int ret = vfs_stat_at(fname, fst); + * change is transparent for non-daemon use. vfs_flags carries the + * operator-path policy (VFS_OPERATOR_PATH for a backup-dir stat). */ + int ret = vfs_stat_at(fname, fst, vfs_flags); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; } -int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) +int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst, int vfs_flags) { - int ret = vfs_lstat_at(fname, fst); + int ret = vfs_lstat_at(fname, fst, vfs_flags); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; From b2260059679647b6b3f66eecd8200519c73df662 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 11:34:42 +1000 Subject: [PATCH 40/69] vfs: delete the operator_path_resolve global With every operator-context call site now passing VFS_OPERATOR_PATH explicitly, nothing reads vfs.operator_path_resolve any more. Remove the four now-dead set/clear blocks (make_backup, handle_partial_dir, and the two generator in-place-backup paths) and delete the field from struct vfs. The operator-supplied path resolution policy -- which selects the ownership walk and the daemon module-root confinement for --backup-dir/--temp-dir/ --partial-dir/--link-dest operations -- is no longer ambient dynamic-scope state poked into the VFS from mainline. It travels as an explicit per-call VFS_OPERATOR_PATH flag through the primitives and compounds, resolving the layering violation that motivated this work. Full suite 190/50. --- backup.c | 2 -- generator.c | 26 ++++++++------------------ util1.c | 4 ---- vfs/vfs.h | 9 ++++----- 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/backup.c b/backup.c index 45c542713..5c55b2012 100644 --- a/backup.c +++ b/backup.c @@ -445,8 +445,6 @@ int make_backup(const char *fname, BOOL prefer_rename) * symlink component is refused while the operator's own is followed -- * absolute and relative alike. --insecure-links / "insecure links =" * restores legacy following. */ - vfs.operator_path_resolve = 1; ret = make_backup_inner(fname, prefer_rename); - vfs.operator_path_resolve = 0; return ret; } diff --git a/generator.c b/generator.c index e38bbe9f0..969e18afd 100644 --- a/generator.c +++ b/generator.c @@ -1161,9 +1161,10 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, #endif /* NB: the copy-dest basis read is deliberately NOT routed through the * ownership walk: copy_altdest_file()->copy_file() also opens the dest - * and copies xattrs through a held O_NOFOLLOW fd, and forcing - * vfs.operator_path_resolve across that re-opens the copy_xattrs parent- - * symlink race (copy-xattrs-symlink-race). basis_link_stat() already + * and copies xattrs through a held O_NOFOLLOW fd, and passing + * VFS_OPERATOR_PATH across that re-opens the copy_xattrs parent- + * symlink race (copy-xattrs-symlink-race) -- so copy_file gets flags 0. + * basis_link_stat() already * refuses a foreign-owned basis symlink, closing the static escape; the * post-stat race on an absolute copy-dest basis is a documented residual. */ if (!dry_run && copy_altdest_file(cmpbuf, fname, file) < 0) { @@ -2249,25 +2250,20 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (read_batch || whole_file) { if (inplace && make_backups > 0 && fnamecmp_type == FNAMECMP_FNAME) { /* The --backup-dir (backupptr) is an operator path; this in-place - * backup bypasses make_backup(), so set vfs.operator_path_resolve here - * too -- get_backup_name() (make_path) and copy_file() then resolve - * it with the ownership walk instead of following any symlink. */ - vfs.operator_path_resolve = 1; + * backup bypasses make_backup(), so get_backup_name() (make_path) and + * copy_file() below are passed VFS_OPERATOR_PATH to resolve it with the + * ownership walk instead of following any symlink. */ if (!(backupptr = get_backup_name(fname))) { - vfs.operator_path_resolve = 0; goto cleanup; } if (!(back_file = make_file(fname, NULL, NULL, 0, NO_FILTERS))) { - vfs.operator_path_resolve = 0; goto pretend_missing; } if (copy_file(fname, backupptr, -1, back_file->mode, VFS_OPERATOR_PATH) < 0) { - vfs.operator_path_resolve = 0; unmake_file(back_file); back_file = NULL; goto cleanup; } - vfs.operator_path_resolve = 0; } goto notify_others; } @@ -2297,17 +2293,13 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (inplace && make_backups > 0 && fnamecmp_type == FNAMECMP_FNAME) { /* Operator --backup-dir, bypassing make_backup(): resolve get_backup_name() * (make_path), the unlink and the create with the ownership walk. */ - vfs.operator_path_resolve = 1; if (!(backupptr = get_backup_name(fname))) { - vfs.operator_path_resolve = 0; goto cleanup; } if (!(back_file = make_file(fname, NULL, NULL, 0, NO_FILTERS))) { - vfs.operator_path_resolve = 0; goto pretend_missing; } if (robust_unlink(backupptr, VFS_OPERATOR_PATH) && errno != ENOENT) { - vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "unlink %s", full_fname(backupptr)); unmake_file(back_file); @@ -2315,13 +2307,11 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, goto cleanup; } if ((f_copy = vfs_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600, VFS_OPERATOR_PATH)) < 0) { - vfs.operator_path_resolve = 0; rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); unmake_file(back_file); back_file = NULL; goto cleanup; } - vfs.operator_path_resolve = 0; fnamecmp_type = FNAMECMP_BACKUP; } @@ -2405,7 +2395,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, if (f_copy >= 0) close(f_copy); /* backupptr's data/xattrs were written safely (confined create under - * vfs.operator_path_resolve, held-fd xattr copy above). This metadata set + * VFS_OPERATOR_PATH, held-fd xattr copy above). This metadata set * re-resolves backupptr by path and is NOT wrapped in operator mode: * set_file_attrs() also drives the path-based xattr set whose held-fd * race-fix operator mode would defeat (cf. the copy-dest note in diff --git a/util1.c b/util1.c index 48ca19235..b94b5289a 100644 --- a/util1.c +++ b/util1.c @@ -1080,26 +1080,22 @@ int handle_partial_dir(const char *fname, int create) * outside the tree): resolve it with the ownership walk -- follow a * uid0/euid-owned symlink, refuse a foreign one, absolute and relative alike. * --insecure-links (or a daemon module's "insecure links =") opts out. */ - vfs.operator_path_resolve = 1; if (create) { STRUCT_STAT st; int statret = vfs_lstat_at(dir, &st, VFS_OPERATOR_PATH); if (statret == 0 && !S_ISDIR(st.st_mode)) { if (vfs_unlink(VFS_AT_FDCWD, dir, VFS_OPERATOR_PATH) < 0) { - vfs.operator_path_resolve = 0; *fn = '/'; return 0; } statret = -1; } if (statret < 0 && vfs_mkdir(VFS_AT_FDCWD, dir, 0700, VFS_OPERATOR_PATH) < 0) { - vfs.operator_path_resolve = 0; *fn = '/'; return 0; } } else vfs_unlink(VFS_AT_FDCWD, dir, VFS_REMOVEDIR); - vfs.operator_path_resolve = 0; *fn = '/'; return 1; diff --git a/vfs/vfs.h b/vfs/vfs.h index 30ea3e8d1..946bffdec 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -59,15 +59,14 @@ /* The single global VFS state instance (defined in vfs/vfs.c). * - * Only curr_dir/curr_dir_len/operator_path_resolve are read by mainline code; - * the dpc cache and the module_* snapshot are VFS-internal (touched only by - * the vfs/ sources) and are documented as such. */ + * Only curr_dir/curr_dir_len are read by mainline code; the dpc cache and the + * module_* snapshot are VFS-internal (touched only by the vfs/ sources) and are + * documented as such. The operator-supplied path resolution policy is no + * longer ambient state -- it travels as an explicit VFS_OPERATOR_PATH flag. */ struct vfs { char curr_dir[MAXPATHLEN]; /* logical cwd (tracked by change_dir) */ unsigned int curr_dir_len; - int operator_path_resolve; /* operator-supplied path resolver mode */ - /* VFS-INTERNAL: held ancestor-dirfd cache. */ struct { const char *anchor; /* anchor path, or sentinel (char *)-2 = none */ From f436561d0844b067ac5c29908cee6fec0670abfb Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 11:49:03 +1000 Subject: [PATCH 41/69] vfs: fix the !SUPPORT_XATTRS x_stat/x_lstat macros for the vfs_flags arg The stat/lstat threading added a vfs_flags argument to x_stat()/x_lstat(), but on a build without xattr support those are 3-arg macros in rsync.h (mapping to plain vfs_stat/vfs_lstat), so the 4-arg call sites failed to compile ("too many arguments to macro"). Give the macros the extra (ignored) parameter. Caught by the fleettest on openbsd; verified with a local --disable-xattr build. --- rsync.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsync.h b/rsync.h index 8fefdaff7..0d3ccf857 100644 --- a/rsync.h +++ b/rsync.h @@ -1241,8 +1241,8 @@ struct name_num_obj { #endif #ifndef SUPPORT_XATTRS -#define x_stat(fn,fst,xst) vfs_stat(fn,fst) -#define x_lstat(fn,fst,xst) vfs_lstat(fn,fst) +#define x_stat(fn,fst,xst,vfsflags) vfs_stat(fn,fst) +#define x_lstat(fn,fst,xst,vfsflags) vfs_lstat(fn,fst) #define x_fstat(fd,fst,xst) vfs_fstat(fd,fst) #endif From 1214c4a2f96be259741de5c914e0b062066c8a51 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 12:20:09 +1000 Subject: [PATCH 42/69] vfs: unify stat/lstat, chmod, lchown into dirfd+flags form Collapse the remaining three-form ops into one call each, matching mkdir/mknod/symlink/unlink: vfs_stat(int dirfd, path, st, flags) vfs_lstat(int dirfd, path, st, flags) vfs_chmod(int dirfd, path, mode, flags) vfs_lchown(int dirfd, path, owner, group, flags) dirfd == VFS_AT_FDCWD resolves the path (VFS_ALLOW_SYMLINK = plain libc op, default 0 = secure receiver resolve, VFS_OPERATOR_PATH = ownership walk for stat); a real held dirfd operates on a single component under it. The old plain/_at/_atfd bodies become static helpers behind the dispatchers; vfs_fstat stays (fd-based). chmod/lchown keep their no-owner-walk behaviour (VFS_OPERATOR_PATH resolves the same as the default secure walk). Pure API-narrowing -- the operator policy was already explicit; no global is involved. This is behaviour-preserving (plain sites -> VFS_ALLOW_SYMLINK, _at -> the flag they already carried, _atfd -> held-fd 0). Held-fd validation differs by op: mkdir/mknod/symlink/unlink reject "."/".." (creating/removing them is nonsensical), but stat/chmod/lchown ALLOW "." (a read or metadata op on the dir itself is legitimate -- link_stat_at and set_file_attrs do it for directory entries); only empty and multi-component ("/") names are rejected there. Caught by the suite (chmod/metadata/ ownership-depth + the implied-"." dir mkdir path). Full suite 190/50. --- backup.c | 4 +-- clientserver.c | 6 ++-- delete.c | 4 +-- flist.c | 8 ++--- generator.c | 6 ++-- main.c | 12 +++---- options.c | 2 +- params.c | 2 +- receiver.c | 2 +- rsync.c | 8 ++--- sender.c | 4 +-- t_chmod_secure.c | 18 +++++----- tls.c | 2 +- util1.c | 4 +-- vfs/chmod.c | 50 ++++++++++++++++++--------- vfs/chown.c | 89 ++++++++++++++++++++++++------------------------ vfs/make_path.c | 4 +-- vfs/mknod.c | 2 +- vfs/open.c | 2 +- vfs/stat.c | 82 ++++++++++++++++++++++++++------------------ vfs/vfs.h | 16 +++------ xattrs.c | 10 +++--- 22 files changed, 180 insertions(+), 157 deletions(-) diff --git a/backup.c b/backup.c index 5c55b2012..5800093eb 100644 --- a/backup.c +++ b/backup.c @@ -65,7 +65,7 @@ static int validate_backup_dir(void) { STRUCT_STAT st; - if (vfs_lstat_at(backup_dir_buf, &st, VFS_OPERATOR_PATH) < 0) { + if (vfs_lstat(VFS_AT_FDCWD, backup_dir_buf, &st, VFS_OPERATOR_PATH) < 0) { if (errno == ENOENT) return 0; rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf); @@ -318,7 +318,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) goto success; if (errno == EEXIST || errno == EISDIR) { STRUCT_STAT bakst; - if (vfs_lstat_at(buf, &bakst, VFS_OPERATOR_PATH) == 0) { + if (vfs_lstat(VFS_AT_FDCWD, buf, &bakst, VFS_OPERATOR_PATH) == 0) { int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE; if (delete_item(buf, bakst.st_mode, flags) != 0) return 0; diff --git a/clientserver.c b/clientserver.c index aab944068..0e7008fa9 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1076,7 +1076,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char STRUCT_STAT st; char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */ strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */ - if (vfs_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { + if (vfs_stat(VFS_AT_FDCWD, prefix, &st, VFS_ALLOW_SYMLINK) == 0 && S_ISDIR(st.st_mode)) { rprintf(FLOG, "Symlink munging is unsafe when a %s directory exists.\n", prefix); io_printf(f_out, "@ERROR: daemon security issue -- contact admin\n", name); @@ -1626,11 +1626,11 @@ static void create_pid_file(void) exit_cleanup(RERR_FILEIO); } } -#define PID_LSTAT(stp) vfs_lstat_atfd(pdfd, base, stp) +#define PID_LSTAT(stp) vfs_lstat(pdfd, base, stp, 0) #define PID_UNLINK() vfs_unlink(pdfd, base, 0) #define PID_OPEN() vfs_open_atfd(pdfd, base, O_RDWR|O_CREAT, 0664) #else -#define PID_LSTAT(stp) vfs_lstat(base, stp) +#define PID_LSTAT(stp) vfs_lstat(VFS_AT_FDCWD, base, stp, VFS_ALLOW_SYMLINK) #define PID_UNLINK() unlink(base) #define PID_OPEN() vfs_open(base, O_RDWR|O_CREAT|SAFE_NOFOLLOW, 0664) #endif diff --git a/delete.c b/delete.c index c5dd5f41b..10e57debe 100644 --- a/delete.c +++ b/delete.c @@ -63,9 +63,9 @@ static void del_chmod(const char *fbuf, mode_t mode) const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); if (dfd >= 0) - vfs_chmod_atfd(dfd, leaf, mode); + vfs_chmod(dfd, leaf, mode, 0); else - vfs_chmod_at(fbuf, mode); + vfs_chmod(VFS_AT_FDCWD, fbuf, mode, 0); } static int del_unlink(const char *fbuf) diff --git a/flist.c b/flist.c index 71bbfe412..763d33600 100644 --- a/flist.c +++ b/flist.c @@ -309,17 +309,17 @@ int link_stat_at(int dfd, const char *name, STRUCT_STAT *stp, int follow_dirlink { #ifdef SUPPORT_LINKS if (copy_links) - return vfs_stat_atfd(dfd, name, stp); - if (vfs_lstat_atfd(dfd, name, stp) < 0) + return vfs_stat(dfd, name, stp, 0); + if (vfs_lstat(dfd, name, stp, 0) < 0) return -1; if (follow_dirlinks && S_ISLNK(stp->st_mode)) { STRUCT_STAT st; - if (vfs_stat_atfd(dfd, name, &st) == 0 && S_ISDIR(st.st_mode)) + if (vfs_stat(dfd, name, &st, 0) == 0 && S_ISDIR(st.st_mode)) *stp = st; } return 0; #else - return vfs_stat_atfd(dfd, name, stp); + return vfs_stat(dfd, name, stp, 0); #endif } diff --git a/generator.c b/generator.c index 969e18afd..396864f27 100644 --- a/generator.c +++ b/generator.c @@ -1395,9 +1395,9 @@ static int gen_entry_chmod(const char *fname, struct file_struct *file, mode_t m int dfd = vfs_cached_dirfd(fname, file); if (dfd >= 0) { const char *slash = strrchr(fname, '/'); - return vfs_chmod_atfd(dfd, slash ? slash + 1 : fname, mode); + return vfs_chmod(dfd, slash ? slash + 1 : fname, mode, 0); } - return vfs_chmod_at(fname, mode); + return vfs_chmod(VFS_AT_FDCWD, fname, mode, 0); } static void gen_entry_set_times(const char *fname, struct file_struct *file, STRUCT_STAT *stp) @@ -1688,7 +1688,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } } if (relative_paths && !implied_dirs && file->mode != 0 - && vfs_stat_at(dn, &sx.st, 0) < 0) { + && vfs_stat(VFS_AT_FDCWD, dn, &sx.st, 0) < 0) { if (dry_run) goto parent_is_dry_missing; if (vfs_make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH, 0) < 0) { diff --git a/main.c b/main.c index af5e67697..475d9f969 100644 --- a/main.c +++ b/main.c @@ -735,7 +735,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) } /* See what currently exists at the destination. */ - statret = vfs_stat(dest_path, &st); + statret = vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK); cp = strrchr(dest_path, '/'); trailing_slash = cp && !cp[1]; @@ -752,7 +752,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) *cp = '/'; } if (ret) - statret = vfs_stat(dest_path, &st); + statret = vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK); else errno = save_errno; } @@ -838,7 +838,7 @@ static char *get_local_name(struct file_list *flist, char *dest_path) dest_path = "/"; *cp = '\0'; - if (dry_run && mkpath_dest_arg && vfs_stat(dest_path, &st) < 0) { + if (dry_run && mkpath_dest_arg && vfs_stat(VFS_AT_FDCWD, dest_path, &st, VFS_ALLOW_SYMLINK) < 0) { /* --mkpath would have created this parent dir, but a dry run did * not, so don't chdir into it; flag the destination as not yet * present (as the dir-creation path above does) so the generator @@ -895,7 +895,7 @@ static void check_alt_basis_dirs(void) pathjoin(new, len, vfs.curr_dir, bdir); basis_dir[j] = bdir = new; } - if (vfs_stat(bdir, &st) < 0) + if (vfs_stat(VFS_AT_FDCWD, bdir, &st, VFS_ALLOW_SYMLINK) < 0) rprintf(FWARNING, "%s arg does not exist: %s\n", alt_dest_opt(0), bdir); else if (!S_ISDIR(st.st_mode)) rprintf(FWARNING, "%s arg is not a dir: %s\n", alt_dest_opt(0), bdir); @@ -1023,7 +1023,7 @@ static int do_recv(int f_in, int f_out, char *local_name) int ret; if (backup_dir_len > 1) backup_dir_buf[backup_dir_len-1] = '\0'; - ret = vfs_stat(backup_dir_buf, &st); + ret = vfs_stat(VFS_AT_FDCWD, backup_dir_buf, &st, VFS_ALLOW_SYMLINK); if (ret != 0 || !S_ISDIR(st.st_mode)) { if (ret == 0) { rprintf(FERROR, "The backup-dir is not a directory: %s\n", backup_dir_buf); @@ -1043,7 +1043,7 @@ static int do_recv(int f_in, int f_out, char *local_name) if (tmpdir) { STRUCT_STAT st; - int ret = vfs_stat(tmpdir, &st); + int ret = vfs_stat(VFS_AT_FDCWD, tmpdir, &st, VFS_ALLOW_SYMLINK); if (ret < 0 || !S_ISDIR(st.st_mode)) { if (ret == 0) { rprintf(FERROR, "The temp-dir is not a directory: %s\n", tmpdir); diff --git a/options.c b/options.c index dda9dcdd0..f61c10ad4 100644 --- a/options.c +++ b/options.c @@ -2372,7 +2372,7 @@ int parse_arguments(int *argc_p, const char ***argv_p) STRUCT_STAT st; char prefix[SYMLINK_PREFIX_LEN]; /* NOT +1 ! */ strlcpy(prefix, SYMLINK_PREFIX, sizeof prefix); /* trim the trailing slash */ - if (vfs_stat(prefix, &st) == 0 && S_ISDIR(st.st_mode)) { + if (vfs_stat(VFS_AT_FDCWD, prefix, &st, VFS_ALLOW_SYMLINK) == 0 && S_ISDIR(st.st_mode)) { rprintf(FERROR, "Symlink munging is unsafe when a %s directory exists.\n", prefix); exit_cleanup(RERR_UNSUPPORTED); diff --git a/params.c b/params.c index a28d23ecc..c3950d994 100644 --- a/params.c +++ b/params.c @@ -416,7 +416,7 @@ static int include_config(char *include, int manage_globals) char *match = manage_globals ? "*.conf" : "*.inc"; int ret; - if (vfs_stat(include, &sb) < 0) { + if (vfs_stat(VFS_AT_FDCWD, include, &sb, VFS_ALLOW_SYMLINK) < 0) { rsyserr(FLOG, errno, "unable to stat config file \"%s\"", include); return 0; } diff --git a/receiver.c b/receiver.c index 4377a37bb..f59af1202 100644 --- a/receiver.c +++ b/receiver.c @@ -1203,7 +1203,7 @@ int recv_files(int f_in, int f_out, char *local_name) chmod_ok = 0; #endif } else - chmod_ok = vfs_chmod_at(fnametmp, 0600) == 0; + chmod_ok = vfs_chmod(VFS_AT_FDCWD, fnametmp, 0600, 0) == 0; if (chmod_ok) { if (use_secure_symlinks) fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); diff --git a/rsync.c b/rsync.c index 4805d6f5e..d81e32d3a 100644 --- a/rsync.c +++ b/rsync.c @@ -676,8 +676,8 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if (am_root >= 0) { uid_t uid = change_uid ? (uid_t)F_OWNER(file) : sxp->st.st_uid; gid_t gid = change_gid ? (gid_t)F_GROUP(file) : sxp->st.st_gid; - if ((dfd >= 0 ? vfs_lchown_atfd(dfd, leaf, uid, gid) - : vfs_lchown_at(fname, uid, gid)) != 0) { + if ((dfd >= 0 ? vfs_lchown(dfd, leaf, uid, gid, 0) + : vfs_lchown(VFS_AT_FDCWD, fname, uid, gid, 0)) != 0) { /* We shouldn't have attempted to change uid * or gid unless have the privilege. */ rsyserr(FERROR_XFER, errno, "%s %s failed", @@ -804,8 +804,8 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, #ifdef HAVE_CHMOD if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { int ret = am_root < 0 ? 0 - : dfd >= 0 && !S_ISLNK(new_mode) ? vfs_chmod_atfd(dfd, leaf, new_mode) - : vfs_chmod_at(fname, new_mode); + : dfd >= 0 && !S_ISLNK(new_mode) ? vfs_chmod(dfd, leaf, new_mode, 0) + : vfs_chmod(VFS_AT_FDCWD, fname, new_mode, 0); if (ret < 0) { rsyserr(FERROR_XFER, errno, "failed to set permissions on %s", diff --git a/sender.c b/sender.c index 2b66f0834..0c86165d7 100644 --- a/sender.c +++ b/sender.c @@ -392,8 +392,8 @@ void successful_send(int ndx) } if (dfd >= 0 - ? (copy_links ? vfs_stat_atfd(dfd, bname, &st) : vfs_lstat_atfd(dfd, bname, &st)) < 0 - : (copy_links ? vfs_stat(fname, &st) : vfs_lstat(fname, &st)) < 0) { + ? (copy_links ? vfs_stat(dfd, bname, &st, 0) : vfs_lstat(dfd, bname, &st, 0)) < 0 + : (copy_links ? vfs_stat(VFS_AT_FDCWD, fname, &st, VFS_ALLOW_SYMLINK) : vfs_lstat(VFS_AT_FDCWD, fname, &st, VFS_ALLOW_SYMLINK)) < 0) { failed_op = "re-lstat"; goto failed; } diff --git a/t_chmod_secure.c b/t_chmod_secure.c index a9362e9ef..da6c31da9 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -1,7 +1,7 @@ /* - * Test harness for vfs_chmod_at(). Confirms the symlink-TOCTOU + * Test harness for vfs_chmod(). Confirms the symlink-TOCTOU * primitive used by CVE-2026-29518 (and its incomplete-fix follow-up - * for chmod) is closed by vfs_chmod_at(): a parent directory component + * for chmod) is closed by vfs_chmod(): a parent directory component * being a symlink that escapes the receiver's confinement must be * rejected, while a parent symlink that resolves *within* the tree * must still work (so legitimate dir-symlinks are not regressed). @@ -31,7 +31,7 @@ short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; static int errs = 0; -/* Does vfs_chmod_at()'s leaf handling refuse to follow a symlink at the final +/* Does vfs_chmod()'s leaf handling refuse to follow a symlink at the final * component? Yes wherever AT_SYMLINK_NOFOLLOW exists; otherwise the wrapper * falls back to a following fchmodat() (documented limitation). Mirrors the * #ifdef ladder in do_fchmodat_nofollow. */ @@ -85,7 +85,7 @@ int main(int argc, char **argv) return 2; } - /* Simulate the daemon-without-chroot deployment that vfs_chmod_at() + /* Simulate the daemon-without-chroot deployment that vfs_chmod() * defends. With am_daemon=0 or am_chrooted=1 the wrapper falls * through to plain vfs_chmod() and the symlink-race test would be * meaningless. */ @@ -112,26 +112,26 @@ int main(int argc, char **argv) * Solaris, older Cygwin, HPE NonStop, pre-5.6 Linux) -- which now follows * an in-tree directory symlink whose target is relative and ".."-free. * Escapes are still rejected on both paths (Scenario B). */ - int rc = vfs_chmod_at("inside_link/sentinel", 0640); + int rc = vfs_chmod(VFS_AT_FDCWD, "inside_link/sentinel", 0640, 0); check("A: legit dir-symlink within tree (followed)", rc, 1, "realdir/sentinel", 0640); /* Scenario B: parent symlink escapes the tree -- chmod must be * rejected and the outside file's mode must be unchanged. */ - rc = vfs_chmod_at("escape_link/sentinel", 0666); + rc = vfs_chmod(VFS_AT_FDCWD, "escape_link/sentinel", 0666, 0); check("B: parent symlink escapes tree (the attack)", rc, 0, "../trap/sentinel", 0600); /* Scenario C: plain relative path with no symlink components, * regression check that the safe wrapper doesn't break the * normal case. */ - rc = vfs_chmod_at("realdir/sentinel", 0644); + rc = vfs_chmod(VFS_AT_FDCWD, "realdir/sentinel", 0644, 0); check("C: plain relative path (regression check)", rc, 1, "realdir/sentinel", 0644); /* Scenario D: top-level file, no parent directory component. * Falls back to vfs_chmod(); should succeed. */ - rc = vfs_chmod_at("topfile", 0640); + rc = vfs_chmod(VFS_AT_FDCWD, "topfile", 0640, 0); check("D: top-level file, no parent component", rc, 1, "topfile", 0640); @@ -141,7 +141,7 @@ int main(int argc, char **argv) * (refused on Linux, lchmod-the-symlink on *BSD/macOS), so assert only that * the outside target's mode is unchanged. */ if (leaf_chmod_nofollow_supported()) { - rc = vfs_chmod_at("realdir/leaflink", 0666); + rc = vfs_chmod(VFS_AT_FDCWD, "realdir/leaflink", 0666, 0); check("E: leaf component is an escaping symlink (must not be followed)", rc, -1, "../trap/sentinel", 0600); } else { diff --git a/tls.c b/tls.c index 42df6ee4f..dd8ccf2d8 100644 --- a/tls.c +++ b/tls.c @@ -160,7 +160,7 @@ static void list_file(const char *fname) char linkbuf[4096]; int nsecs; - if (vfs_lstat(fname, &buf) < 0) + if (vfs_lstat(VFS_AT_FDCWD, fname, &buf, VFS_ALLOW_SYMLINK) < 0) failed("stat", fname); #ifdef SUPPORT_CRTIMES if (display_crtimes && (crtime = vfs_get_create_time(fname, &buf)) == 0) diff --git a/util1.c b/util1.c index b94b5289a..c43e910b1 100644 --- a/util1.c +++ b/util1.c @@ -366,7 +366,7 @@ static inline void call_glob_match(const char *name, int len, int from_glob, STRUCT_STAT st; int is_dir; - if (vfs_stat(glob.arg_buf, &st) != 0) + if (vfs_stat(VFS_AT_FDCWD, glob.arg_buf, &st, VFS_ALLOW_SYMLINK) != 0) return; is_dir = S_ISDIR(st.st_mode) != 0; if (arg && !is_dir) @@ -1082,7 +1082,7 @@ int handle_partial_dir(const char *fname, int create) * --insecure-links (or a daemon module's "insecure links =") opts out. */ if (create) { STRUCT_STAT st; - int statret = vfs_lstat_at(dir, &st, VFS_OPERATOR_PATH); + int statret = vfs_lstat(VFS_AT_FDCWD, dir, &st, VFS_OPERATOR_PATH); if (statret == 0 && !S_ISDIR(st.st_mode)) { if (vfs_unlink(VFS_AT_FDCWD, dir, VFS_OPERATOR_PATH) < 0) { *fn = '/'; diff --git a/vfs/chmod.c b/vfs/chmod.c index 269beeb42..02cd81cbd 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -24,7 +24,7 @@ #endif #ifdef HAVE_CHMOD -int vfs_chmod(const char *path, mode_t mode) +static int vfs__chmod_plain(const char *path, mode_t mode) { static int switch_step = 0; int code; @@ -88,7 +88,7 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) # if defined __linux__ { STRUCT_STAT st; - if (vfs_lstat_atfd(dfd, name, &st) < 0) + if (vfs_lstat(dfd, name, &st, 0) < 0) return -1; if (S_ISLNK(st.st_mode)) { errno = ELOOP; /* refuse to chmod through a symlink leaf */ @@ -162,8 +162,9 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) Falls back to vfs_chmod() for absolute paths and for paths with no parent component, where there is nothing to protect against. */ -int vfs_chmod_at(const char *fname, mode_t mode) +static int vfs__chmod_secure(const char *fname, mode_t mode, int flags) { + (void)flags; /* chmod has no ownership-walk branch (like vfs_lchown) */ #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; const char *bname; @@ -181,14 +182,14 @@ int vfs_chmod_at(const char *fname, mode_t mode) * already access. Everywhere else, fall through to plain * vfs_chmod() to avoid the dirfd-open overhead on every call. */ if (!vfs_relpath_active()) - return vfs_chmod(fname, mode); + return vfs__chmod_plain(fname, mode); if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) - return vfs_chmod(fname, mode); + return vfs__chmod_plain(fname, mode); slash = strrchr(fname, '/'); if (!slash) - return vfs_chmod(fname, mode); + return vfs__chmod_plain(fname, mode); dlen = slash - fname; if (dlen >= sizeof dirpath) { @@ -209,26 +210,41 @@ int vfs_chmod_at(const char *fname, mode_t mode) errno = e; return ret; #else - return vfs_chmod(fname, mode); + return vfs__chmod_plain(fname, mode); #endif } #endif +/* Unified chmod. dirfd == VFS_AT_FDCWD resolves `path`; a real held dirfd makes + * `path` a single component chmod'd (no-follow leaf, via do_fchmodat_nofollow) + * under it. flags: VFS_ALLOW_SYMLINK (trusted, plain chmod), default 0 (secure + * receiver resolve). A symlink-as-object (S_ISLNK(mode)) goes through the plain + * lchmod/setattrlist path. */ #ifdef HAVE_CHMOD -int vfs_chmod_atfd(int dfd, const char *name, mode_t mode) +int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags) { -#ifdef AT_FDCWD if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - /* Do not follow a final-component symlink (closes the leaf race; the - * held parent dfd already confines the ancestors). A symlink-as-object - * (S_ISLNK(mode)) is still handled by the caller via the full-path - * vfs_chmod() lchmod/setattrlist code, exactly as vfs_chmod_at() does. */ - return do_fchmodat_nofollow(dfd, name, mode); + RETURN_ERROR_IF_NULL(path); + + if (dirfd != VFS_AT_FDCWD) { +#ifdef AT_FDCWD + /* Held-fd: reject empty and multi-component; "." (chmod the dir + * itself) is a legitimate single-component op. */ + if (!*path || strchr(path, '/')) { + errno = EINVAL; + return -1; + } + return do_fchmodat_nofollow(dirfd, path, mode); #else - (void)dfd; (void)name; (void)mode; - errno = ENOSYS; - return -1; + (void)dirfd; (void)mode; + errno = ENOSYS; + return -1; #endif + } + + if (flags & VFS_ALLOW_SYMLINK) + return vfs__chmod_plain(path, mode); + return vfs__chmod_secure(path, mode, flags); } #endif diff --git a/vfs/chown.c b/vfs/chown.c index 6cc9d15d4..82a3b6abf 100644 --- a/vfs/chown.c +++ b/vfs/chown.c @@ -16,85 +16,84 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -int vfs_lchown(const char *path, uid_t owner, gid_t group) -{ - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - RETURN_ERROR_IF_NULL(path); #ifndef HAVE_LCHOWN #define lchown chown #endif + +static int vfs__lchown_plain(const char *path, uid_t owner, gid_t group) +{ return lchown(path, owner, group); } -/* - Symlink-race-safe variant of vfs_lchown() for receiver-side use. See the - comment on vfs_chmod_at() for the threat model and design rationale. - - Resolves the parent directory under vfs_resolve_open() and invokes - fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an - attacker who substitutes a symlink into one of the parent components - cannot redirect the chown outside the receiver's confinement. The - AT_SYMLINK_NOFOLLOW flag matches lchown()'s "do not follow a final- - component symlink" semantics. - - Falls through to vfs_lchown() in the dry-run / non-daemon / chrooted / - absolute-path / no-parent cases, identical to vfs_chmod_at(). -*/ -int vfs_lchown_at(const char *fname, uid_t owner, gid_t group) +/* Secure receiver-side resolve: open the parent under vfs_resolve_open() and + * fchownat(..., AT_SYMLINK_NOFOLLOW) so a parent-component symlink swap can't + * redirect the chown outside the module. Like vfs_chmod, lchown has no + * ownership-walk branch (VFS_OPERATOR_PATH resolves the same as the default + * secure walk). Falls through to the plain lchown in non-daemon/sender, + * chrooted, no-parent and absolute-path cases. */ +static int vfs__lchown_secure(const char *path, uid_t owner, gid_t group, int flags) { -#ifdef AT_FDCWD + (void)flags; +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY char dirpath[MAXPATHLEN]; - const char *bname; - const char *slash; + const char *bname, *slash; int dfd, ret, e; size_t dlen; - if (dry_run) return 0; - RETURN_ERROR_IF_RO_OR_LO; - - if (!vfs_relpath_active()) - return vfs_lchown(fname, owner, group); - - if (!fname || !*fname || *fname == '/') - return vfs_lchown(fname, owner, group); - - slash = strrchr(fname, '/'); + if (!vfs_relpath_active() || !*path || *path == '/') + return vfs__lchown_plain(path, owner, group); + slash = strrchr(path, '/'); if (!slash) - return vfs_lchown(fname, owner, group); - - dlen = slash - fname; + return vfs__lchown_plain(path, owner, group); + dlen = slash - path; if (dlen >= sizeof dirpath) { errno = ENAMETOOLONG; return -1; } - memcpy(dirpath, fname, dlen); + memcpy(dirpath, path, dlen); dirpath[dlen] = '\0'; bname = slash + 1; dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); if (dfd < 0) return -1; - ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); e = errno; close(dfd); errno = e; return ret; #else - return vfs_lchown(fname, owner, group); + return vfs__lchown_plain(path, owner, group); #endif } -int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group) +/* Unified lchown. dirfd == VFS_AT_FDCWD resolves `path`; a real held dirfd + * makes `path` a single component chowned (no-follow) under it. flags: + * VFS_ALLOW_SYMLINK (trusted, plain lchown), default 0 (secure receiver + * resolve). */ +int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags) { -#ifdef AT_FDCWD if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - return fchownat(dfd, name, owner, group, AT_SYMLINK_NOFOLLOW); + RETURN_ERROR_IF_NULL(path); + + if (dirfd != VFS_AT_FDCWD) { +#ifdef AT_FDCWD + /* Held-fd: reject empty and multi-component; "." (chown the dir + * itself) is a legitimate single-component op. */ + if (!*path || strchr(path, '/')) { + errno = EINVAL; + return -1; + } + return fchownat(dirfd, path, owner, group, AT_SYMLINK_NOFOLLOW); #else - (void)dfd; (void)name; (void)owner; (void)group; - errno = ENOSYS; - return -1; + (void)dirfd; (void)owner; (void)group; + errno = ENOSYS; + return -1; #endif + } + + if (flags & VFS_ALLOW_SYMLINK) + return vfs__lchown_plain(path, owner, group); + return vfs__lchown_secure(path, owner, group, flags); } diff --git a/vfs/make_path.c b/vfs/make_path.c index 555cacbaf..192d3680d 100644 --- a/vfs/make_path.c +++ b/vfs/make_path.c @@ -48,7 +48,7 @@ int vfs_make_path(char *fname, int mkp_flags, int vfs_flags) for (p = end; ; ) { if (dry_run) { STRUCT_STAT st; - if (vfs_stat(fname, &st) == 0) { + if (vfs_stat(VFS_AT_FDCWD, fname, &st, VFS_ALLOW_SYMLINK) == 0) { if (S_ISDIR(st.st_mode)) errno = EEXIST; else @@ -61,7 +61,7 @@ int vfs_make_path(char *fname, int mkp_flags, int vfs_flags) if (errno != ENOENT) { STRUCT_STAT st; - if (errno != EEXIST || (vfs_stat(fname, &st) == 0 && !S_ISDIR(st.st_mode))) + if (errno != EEXIST || (vfs_stat(VFS_AT_FDCWD, fname, &st, VFS_ALLOW_SYMLINK) == 0 && !S_ISDIR(st.st_mode))) ret = -ret - 1; break; } diff --git a/vfs/mknod.c b/vfs/mknod.c index 8f6761d96..2503ec563 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -68,7 +68,7 @@ static int vfs__mknod_plain(const char *pathname, mode_t mode, dev_t dev) return -1; close(sock); #ifdef HAVE_CHMOD - return vfs_chmod(pathname, mode); + return vfs_chmod(VFS_AT_FDCWD, pathname, mode, VFS_ALLOW_SYMLINK); #else return 0; #endif diff --git a/vfs/open.c b/vfs/open.c index f6b660e55..8c9250796 100644 --- a/vfs/open.c +++ b/vfs/open.c @@ -141,7 +141,7 @@ int vfs_open_nofollow(const char *pathname, int flags) #ifdef O_NOFOLLOW fd = open(pathname, flags|O_NOFOLLOW); #else - if (vfs_lstat(pathname, &l_st) < 0) + if (vfs_lstat(VFS_AT_FDCWD, pathname, &l_st, VFS_ALLOW_SYMLINK) < 0) return -1; if (S_ISLNK(l_st.st_mode)) { errno = ELOOP; diff --git a/vfs/stat.c b/vfs/stat.c index 2ee591aba..fc34cfbc8 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -16,7 +16,7 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -int vfs_stat(const char *path, STRUCT_STAT *st) +static int vfs__stat_plain(const char *path, STRUCT_STAT *st) { RETURN_ERROR_IF_NULL(path); #ifdef USE_STAT64_FUNCS @@ -26,7 +26,7 @@ int vfs_stat(const char *path, STRUCT_STAT *st) #endif } -int vfs_lstat(const char *path, STRUCT_STAT *st) +static int vfs__lstat_plain(const char *path, STRUCT_STAT *st) { RETURN_ERROR_IF_NULL(path); #ifdef SUPPORT_LINKS @@ -36,7 +36,7 @@ int vfs_lstat(const char *path, STRUCT_STAT *st) return lstat(path, st); # endif #else - return vfs_stat(path, st); + return vfs__stat_plain(path, st); #endif } @@ -110,51 +110,67 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa #endif } -int vfs_stat_at(const char *path, STRUCT_STAT *st, int vfs_flags) +/* Unified stat/lstat. dirfd == VFS_AT_FDCWD resolves `path` (VFS_ALLOW_SYMLINK = + * plain libc stat/lstat, default 0 = secure receiver resolve, VFS_OPERATOR_PATH = + * ownership walk); a real held dirfd fstatat()s a single validated component + * under it. vfs_stat follows the leaf, vfs_lstat does not. */ +int vfs_stat(int dirfd, const char *path, STRUCT_STAT *st, int flags) { - return do_xstat_at(path, st, 0, vfs_stat, vfs_flags); -} - -int vfs_lstat_at(const char *path, STRUCT_STAT *st, int vfs_flags) -{ -#ifdef SUPPORT_LINKS - return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs_lstat, vfs_flags); + RETURN_ERROR_IF_NULL(path); + if (dirfd != VFS_AT_FDCWD) { +#ifdef AT_FDCWD + /* Held-fd: reject empty and multi-component (a '/' would resolve a + * path under the pinned dir). "." / ".." are allowed: a read-only + * fstatat of the dir or its parent is legitimate (link_stat_at). */ + if (!*path || strchr(path, '/')) { + errno = EINVAL; + return -1; + } + return fstatat(dirfd, path, st, 0); #else - return do_xstat_at(path, st, 0, vfs_stat, vfs_flags); -#endif -} - -int vfs_fstat(int fd, STRUCT_STAT *st) -{ -#ifdef USE_STAT64_FUNCS - return fstat64(fd, st); -#else - return fstat(fd, st); + (void)dirfd; errno = ENOSYS; return -1; #endif + } + if (flags & VFS_ALLOW_SYMLINK) + return vfs__stat_plain(path, st); + return do_xstat_at(path, st, 0, vfs__stat_plain, flags); } -int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st) +int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags) { + RETURN_ERROR_IF_NULL(path); + if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD + /* Held-fd: reject empty and multi-component; "." / ".." are allowed + * (read-only fstatat of the dir or its parent -- link_stat_at). */ + if (!*path || strchr(path, '/')) { + errno = EINVAL; + return -1; + } # ifdef SUPPORT_LINKS - return fstatat(dfd, name, st, AT_SYMLINK_NOFOLLOW); + return fstatat(dirfd, path, st, AT_SYMLINK_NOFOLLOW); # else - return fstatat(dfd, name, st, 0); + return fstatat(dirfd, path, st, 0); # endif #else - (void)dfd; (void)name; (void)st; - errno = ENOSYS; - return -1; + (void)dirfd; errno = ENOSYS; return -1; +#endif + } + if (flags & VFS_ALLOW_SYMLINK) + return vfs__lstat_plain(path, st); +#ifdef SUPPORT_LINKS + return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs__lstat_plain, flags); +#else + return do_xstat_at(path, st, 0, vfs__stat_plain, flags); #endif } -int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st) +int vfs_fstat(int fd, STRUCT_STAT *st) { -#ifdef AT_FDCWD - return fstatat(dfd, name, st, 0); +#ifdef USE_STAT64_FUNCS + return fstat64(fd, st); #else - (void)dfd; (void)name; (void)st; - errno = ENOSYS; - return -1; + return fstat(fd, st); #endif } + diff --git a/vfs/vfs.h b/vfs/vfs.h index 946bffdec..2c8670c15 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -110,13 +110,9 @@ int vfs_cached_dirfd(const char *path, const struct file_struct *file); void vfs_dircache_reset(void); /* stat/lstat/fstat (vfs/stat.c). */ -int vfs_stat(const char *path, STRUCT_STAT *st); -int vfs_lstat(const char *path, STRUCT_STAT *st); +int vfs_stat(int dirfd, const char *path, STRUCT_STAT *st, int flags); +int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags); int vfs_fstat(int fd, STRUCT_STAT *st); -int vfs_stat_at(const char *path, STRUCT_STAT *st, int vfs_flags); -int vfs_lstat_at(const char *path, STRUCT_STAT *st, int vfs_flags); -int vfs_stat_atfd(int dfd, const char *name, STRUCT_STAT *st); -int vfs_lstat_atfd(int dfd, const char *name, STRUCT_STAT *st); /* rename (vfs/rename.c). */ int vfs_rename(const char *old_path, const char *new_path); @@ -134,9 +130,7 @@ int vfs_open_nofollow(const char *pathname, int flags); int vfs_open_checklinks(const char *pathname); /* chmod (vfs/chmod.c). */ -int vfs_chmod(const char *path, mode_t mode); -int vfs_chmod_at(const char *fname, mode_t mode); -int vfs_chmod_atfd(int dfd, const char *name, mode_t mode); +int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags); /* symlink/readlink (vfs/symlink.c). vfs_readlink is a function only in * fake-super builds; otherwise it is a macro -> readlink() (see rsync.h). */ @@ -162,9 +156,7 @@ int vfs_mkstemp_atfd(int dfd, char *filename, mode_t perms); int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path); /* lchown (vfs/chown.c). */ -int vfs_lchown(const char *path, uid_t owner, gid_t group); -int vfs_lchown_at(const char *fname, uid_t owner, gid_t group); -int vfs_lchown_atfd(int dfd, const char *name, uid_t owner, gid_t group); +int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags); /* device/fifo/socket node creation (vfs/mknod.c). */ int vfs_mknod(int dirfd, const char *path, mode_t mode, dev_t dev, int flags); diff --git a/xattrs.c b/xattrs.c index 42613f417..46a63578b 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1154,7 +1154,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna #endif && access(fname, W_OK) < 0 && (fd >= 0 ? fchmod(fd, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) - : vfs_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR)) == 0) + : vfs_chmod(VFS_AT_FDCWD, fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR, 0)) == 0) added_write_perm = 1; ndx = F_XATTR(file); @@ -1177,7 +1177,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna if (fd >= 0) fchmod(fd, sxp->st.st_mode); else - vfs_chmod_at(fname, sxp->st.st_mode); + vfs_chmod(VFS_AT_FDCWD, fname, sxp->st.st_mode, 0); } return return_value; } @@ -1317,7 +1317,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode, if (fd >= 0) fchmod(fd, mode); else - vfs_chmod_at(fname, mode); + vfs_chmod(VFS_AT_FDCWD, fname, mode, 0); } if (!IS_DEVICE(fst.st_mode)) fst.st_rdev = 0; /* just in case */ @@ -1364,7 +1364,7 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst, int vfs_flags) * plain vfs_stat outside the daemon-no-chroot context, so this * change is transparent for non-daemon use. vfs_flags carries the * operator-path policy (VFS_OPERATOR_PATH for a backup-dir stat). */ - int ret = vfs_stat_at(fname, fst, vfs_flags); + int ret = vfs_stat(VFS_AT_FDCWD, fname, fst, vfs_flags); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; @@ -1372,7 +1372,7 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst, int vfs_flags) int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst, int vfs_flags) { - int ret = vfs_lstat_at(fname, fst, vfs_flags); + int ret = vfs_lstat(VFS_AT_FDCWD, fname, fst, vfs_flags); if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) xst->st_mode = 0; return ret; From b61342e4c9c2dca1ec3375c5ef316827c93d4d06 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 12:22:17 +1000 Subject: [PATCH 43/69] vfs: docs pass for the unified dirfd+flags API Rewrite the vfs.h header contract to describe the current model: the two-layer vfs/ structure (primitives + compounds), the single vfs_(int dirfd, ..., int flags) form, the meaning of VFS_AT_FDCWD / VFS_ALLOW_SYMLINK / VFS_OPERATOR_PATH / VFS_REMOVEDIR, that the operator policy is an explicit per-call flag (not ambient state), and which ops keep explicit forms (rename/link two-path, open variants, fd-based fstat/fileio). Update the stale threat-model cross-references that pointed at the old vfs_chmod_at() (now the static vfs__chmod_secure()) and the two vfs_mknod_at()/vfs_symlink_at() mentions left by the unification. --- vfs/link.c | 2 +- vfs/mknod.c | 4 ++-- vfs/open.c | 2 +- vfs/rename.c | 2 +- vfs/stat.c | 2 +- vfs/symlink.c | 4 ++-- vfs/times.c | 2 +- vfs/vfs.h | 41 +++++++++++++++++++++++++++-------------- 8 files changed, 36 insertions(+), 23 deletions(-) diff --git a/vfs/link.c b/vfs/link.c index c76c268a1..aebe20ad6 100644 --- a/vfs/link.c +++ b/vfs/link.c @@ -32,7 +32,7 @@ int vfs_link(const char *old_path, const char *new_path) /* Symlink-race-safe variant of vfs_link() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. link() resolves + the comment on vfs__chmod_secure() for the threat model. link() resolves parent components of *both* old_path and new_path, so a parent- symlink swap on either side can plant the new hard link outside the module, or hard-link an outside file into the module (read diff --git a/vfs/mknod.c b/vfs/mknod.c index 2503ec563..d4d14e0d6 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -84,7 +84,7 @@ static int vfs__mknod_plain(const char *pathname, mode_t mode, dev_t dev) /* Symlink-race-safe variant of vfs_mknod() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. Defence: open + the comment on vfs__chmod_secure() for the threat model. Defence: open the parent of pathname under vfs_resolve_open() and use mknodat() against that dirfd. mknodat() covers both regular-file (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. @@ -242,7 +242,7 @@ static int vfs__mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) #endif if (S_ISSOCK(mode)) { /* No dirfd-relative socket bind without /proc/self/fd; fail safe. - * (The generator routes sockets to vfs_mknod_at(), not here.) */ + * (The generator routes sockets to vfs_mknod(), not here.) */ errno = EOPNOTSUPP; return -1; } diff --git a/vfs/open.c b/vfs/open.c index 8c9250796..57d08d82f 100644 --- a/vfs/open.c +++ b/vfs/open.c @@ -34,7 +34,7 @@ int vfs_open(const char *pathname, int flags, mode_t mode) /* Symlink-race-safe variant of vfs_open() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. open() resolves + the comment on vfs__chmod_secure() for the threat model. open() resolves parent components, so a parent-symlink swap can redirect the open to a file outside the module. This wrapper is defence-in-depth for bare-path vfs_open() sites that callers know are otherwise diff --git a/vfs/rename.c b/vfs/rename.c index ba7c6cd0f..03765a072 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -25,7 +25,7 @@ int vfs_rename(const char *old_path, const char *new_path) /* Symlink-race-safe variant of vfs_rename() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model and design rationale. + the comment on vfs__chmod_secure() for the threat model and design rationale. rename() is the central tmp -> final operation in rsync; if either the source or the destination has an attacker-substituted symlink in one diff --git a/vfs/stat.c b/vfs/stat.c index fc34cfbc8..f9e6cfc4d 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -42,7 +42,7 @@ static int vfs__lstat_plain(const char *path, STRUCT_STAT *st) /* Symlink-race-safe variants of vfs_stat() / vfs_lstat() for receiver- - side use. See the comment on vfs_chmod_at() for the threat model. + side use. See the comment on vfs__chmod_secure() for the threat model. stat() and lstat() resolve parent components, so a parent-symlink swap can make the receiver's stat see attributes of a victim file outside the module -- which then drives later behaviour (e.g. diff --git a/vfs/symlink.c b/vfs/symlink.c index 75ed7d2d6..d223093e0 100644 --- a/vfs/symlink.c +++ b/vfs/symlink.c @@ -46,7 +46,7 @@ static int vfs__symlink_plain(const char *lnk, const char *path) /* Symlink-race-safe variant of vfs_symlink() for receiver-side use. See - the comment on vfs_chmod_at() for the threat model. For a real symlink + the comment on vfs__chmod_secure() for the threat model. For a real symlink only the parent directory of `path` needs protection -- symlinkat() does not resolve the final component (it creates it). Defence: open the parent of `path` under vfs_resolve_open() and call symlinkat() @@ -205,7 +205,7 @@ static int vfs__symlink_atfd(const char *lnk, int dfd, const char *name) #if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS /* --fake-super: store the link target in a regular placeholder file, * created with O_NOFOLLOW so a planted basename symlink can't redirect - * the write (mirrors vfs_symlink_at()). */ + * the write (mirrors vfs__symlink_secure()). */ if (am_root < 0) { int len = strlen(lnk); int ok; diff --git a/vfs/times.c b/vfs/times.c index 08b4f2d94..0ebd1d3b0 100644 --- a/vfs/times.c +++ b/vfs/times.c @@ -177,7 +177,7 @@ int vfs_utimensat(const char *path, STRUCT_STAT *stp) /* Symlink-race-safe variant of vfs_utimensat() for receiver-side use. - See the comment on vfs_chmod_at() for the threat model. utimes() + See the comment on vfs__chmod_secure() for the threat model. utimes() resolves parent components and follows a final-component symlink; lutimes() doesn't follow the final component but still resolves parents. Either way, a parent-symlink swap can redirect the diff --git a/vfs/vfs.h b/vfs/vfs.h index 2c8670c15..e1ed1c0ec 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -4,21 +4,34 @@ * The VFS owns the messy, security-critical filesystem details (the vfs_* * syscall wrappers, the race-safe path resolver, the held-dirfd cache, the * operator-path ownership walk and the daemon module confinement) so the - * mainline protocol/transfer code can stay clean. The state lives in the - * single global "struct vfs vfs" below; the implementations are in the - * per-concern sources under vfs/ (one per operation family, plus the resolver, - * dirstack, dircache and owner-walk cores). + * mainline protocol/transfer code can stay clean. Implementations live in the + * per-concern sources under vfs/ in two layers: PRIMITIVES (one file per + * operation family, plus the resolver/dirstack/dircache/owner-walk cores) and + * COMPOUNDS built on them (vfs_make_path, copy_file, robust_unlink/rename). The + * remaining VFS-internal state lives in the single "struct vfs vfs" below. * - * Most operations come in up to three forms, sharing one leaf behaviour: - * vfs_(path, ...) - operate on a path as given (the plain wrapper; - * honours dry-run / read-only). - * vfs__at(path, ...) - resolve the parent components race-safely - * (vfs_resolve_open) and act on the leaf with - * *at()/O_NOFOLLOW; the receiver's TOCTOU-safe - * form, used when vfs_relpath_active(). - * vfs__atfd(dfd, name, ) - act on a single component `name` under an - * already-pinned dirfd (from vfs_opendir / - * vfs_get_dirfd); no path resolution. + * Each single-path operation is one call taking a dirfd and a flags word: + * + * vfs_(int dirfd, path, ..., int flags) + * + * dirfd == VFS_AT_FDCWD -- resolve `path`, per flags: + * 0 secure receiver resolve (race-safe O_NOFOLLOW parent + * walk + *at() leaf) when vfs_relpath_active(), else plain + * VFS_ALLOW_SYMLINK plain libc op -- the call site asserts the path is + * trusted to follow symlinks (was the bare vfs_()) + * VFS_OPERATOR_PATH operator-supplied path (--backup-dir/--temp-dir/ + * --partial-dir/--link-dest): ownership walk (follow a + * uid0/euid-owned symlink, refuse a foreign one) + + * daemon module-root confinement + * a real held dirfd -- `path` is a single component acted on directly under + * it (from vfs_opendir/vfs_get_dirfd); no resolution. + * + * The operator-path policy is this explicit per-call flag, never ambient state. + * VFS_REMOVEDIR turns vfs_unlink into rmdir. chmod/lchown/symlink have no + * ownership-walk branch (VFS_OPERATOR_PATH resolves as the default secure walk). + * Two-path ops (vfs_rename_at, vfs_link_at) and open (distinct nofollow/ + * checklinks variants) keep explicit forms + a vfs_flags arg; vfs_fstat and the + * fileio ops are fd-based. * * This header is included by rsync.h (just after proto.h) so every translation * unit sees the vfs_* API. It must not include rsync.h itself. From af68dac222f672a40a0e0f22c7c76016b0c74b3d Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 12:34:58 +1000 Subject: [PATCH 44/69] vfs: update the !SUPPORT_XATTRS x_stat/x_lstat macros for the unified stat vfs_stat/vfs_lstat are now vfs_(int dirfd, path, st, int flags); the no-xattr fallback macros still expanded to the old 2-arg vfs_stat(fn,fst), breaking the build without xattr support. Map them to the unified plain form: vfs_stat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) (and lstat), which preserves the prior plain-stat behaviour. Caught by the fleettest on openbsd; verified with a local --disable-xattr build. --- rsync.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rsync.h b/rsync.h index 0d3ccf857..0594ac65c 100644 --- a/rsync.h +++ b/rsync.h @@ -1241,8 +1241,8 @@ struct name_num_obj { #endif #ifndef SUPPORT_XATTRS -#define x_stat(fn,fst,xst,vfsflags) vfs_stat(fn,fst) -#define x_lstat(fn,fst,xst,vfsflags) vfs_lstat(fn,fst) +#define x_stat(fn,fst,xst,vfsflags) vfs_stat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) +#define x_lstat(fn,fst,xst,vfsflags) vfs_lstat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) #define x_fstat(fd,fst,xst) vfs_fstat(fd,fst) #endif From 18e30dd23eb2409d0c6646a9ae55f821c6b884ed Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 13:01:07 +1000 Subject: [PATCH 45/69] vfs: codex review fixes for the unified stat/chmod/lchown Three issues from the post-unification review: - The !SUPPORT_XATTRS x_stat/x_lstat macros hardcoded VFS_ALLOW_SYMLINK, silently downgrading an operator-path stat (backup-dir) from the ownership walk + module confinement to plain follow-stat in no-xattr builds (a pre-existing gap the unified vfs_stat now lets us close). Forward the caller's vfs_flags instead, so no-xattr builds get the same policy as xattr builds. - Held-fd ".." was accepted by the stat/chmod/lchown dispatchers, which would operate on the PARENT of the pinned dirfd -- the wrong default for a security boundary even though no current call site passes it. Reject ".." while still allowing "." (a stat/chmod/chown of the dir itself, which link_stat_at and set_file_attrs legitimately do for directory entries). - Two stale comment/test strings still said vfs_chmod_at (now vfs_chmod). Verified with a local --disable-xattr build; full suite 190/50. --- receiver.c | 2 +- rsync.h | 4 ++-- t_chmod_secure.c | 2 +- vfs/chmod.c | 8 +++++--- vfs/chown.c | 8 +++++--- vfs/stat.c | 17 ++++++++++------- 6 files changed, 24 insertions(+), 17 deletions(-) diff --git a/receiver.c b/receiver.c index f59af1202..27c038c7d 100644 --- a/receiver.c +++ b/receiver.c @@ -1188,7 +1188,7 @@ int recv_files(int f_in, int f_out, char *local_name) * (its mode is restored after the transfer). On a * non-chroot daemon fchmod() a no-follow fd rather than * chmod the path, so a symlink raced into fnametmp can't - * redirect the chmod (vfs_chmod_at follows the final link). */ + * redirect the chmod (vfs_chmod follows the final link). */ int errno_save = errno, chmod_ok; if (use_secure_symlinks) { #ifdef O_NOFOLLOW diff --git a/rsync.h b/rsync.h index 0594ac65c..1749be1f1 100644 --- a/rsync.h +++ b/rsync.h @@ -1241,8 +1241,8 @@ struct name_num_obj { #endif #ifndef SUPPORT_XATTRS -#define x_stat(fn,fst,xst,vfsflags) vfs_stat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) -#define x_lstat(fn,fst,xst,vfsflags) vfs_lstat(VFS_AT_FDCWD, fn, fst, VFS_ALLOW_SYMLINK) +#define x_stat(fn,fst,xst,vfsflags) vfs_stat(VFS_AT_FDCWD, fn, fst, vfsflags) +#define x_lstat(fn,fst,xst,vfsflags) vfs_lstat(VFS_AT_FDCWD, fn, fst, vfsflags) #define x_fstat(fd,fst,xst) vfs_fstat(fd,fst) #endif diff --git a/t_chmod_secure.c b/t_chmod_secure.c index da6c31da9..607c995a3 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -146,7 +146,7 @@ int main(int argc, char **argv) rc, -1, "../trap/sentinel", 0600); } else { fprintf(stderr, "INFO: leaf-nofollow chmod unsupported here; " - "vfs_chmod_at follows a leaf symlink (documented limitation), " + "vfs_chmod follows a leaf symlink (documented limitation), " "skipping scenario E\n"); } diff --git a/vfs/chmod.c b/vfs/chmod.c index 02cd81cbd..9022c6c68 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -229,9 +229,11 @@ int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags) if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD - /* Held-fd: reject empty and multi-component; "." (chmod the dir - * itself) is a legitimate single-component op. */ - if (!*path || strchr(path, '/')) { + /* Held-fd: reject empty, multi-component and ".." (writing the + * parent of the pinned dir); "." (chmod the dir itself) is a + * legitimate single-component op. */ + if (!*path || strchr(path, '/') + || (path[0] == '.' && path[1] == '.' && path[2] == '\0')) { errno = EINVAL; return -1; } diff --git a/vfs/chown.c b/vfs/chown.c index 82a3b6abf..c396574c9 100644 --- a/vfs/chown.c +++ b/vfs/chown.c @@ -79,9 +79,11 @@ int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags) if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD - /* Held-fd: reject empty and multi-component; "." (chown the dir - * itself) is a legitimate single-component op. */ - if (!*path || strchr(path, '/')) { + /* Held-fd: reject empty, multi-component and ".." (writing the + * parent of the pinned dir); "." (chown the dir itself) is a + * legitimate single-component op. */ + if (!*path || strchr(path, '/') + || (path[0] == '.' && path[1] == '.' && path[2] == '\0')) { errno = EINVAL; return -1; } diff --git a/vfs/stat.c b/vfs/stat.c index f9e6cfc4d..f9ac47614 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -119,10 +119,12 @@ int vfs_stat(int dirfd, const char *path, STRUCT_STAT *st, int flags) RETURN_ERROR_IF_NULL(path); if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD - /* Held-fd: reject empty and multi-component (a '/' would resolve a - * path under the pinned dir). "." / ".." are allowed: a read-only - * fstatat of the dir or its parent is legitimate (link_stat_at). */ - if (!*path || strchr(path, '/')) { + /* Held-fd: reject empty, multi-component (a '/' would resolve a path + * under the pinned dir) and ".." (escapes to the parent). "." is + * allowed: a read-only fstatat of the dir itself is legitimate + * (link_stat_at). */ + if (!*path || strchr(path, '/') + || (path[0] == '.' && path[1] == '.' && path[2] == '\0')) { errno = EINVAL; return -1; } @@ -141,9 +143,10 @@ int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags) RETURN_ERROR_IF_NULL(path); if (dirfd != VFS_AT_FDCWD) { #ifdef AT_FDCWD - /* Held-fd: reject empty and multi-component; "." / ".." are allowed - * (read-only fstatat of the dir or its parent -- link_stat_at). */ - if (!*path || strchr(path, '/')) { + /* Held-fd: reject empty, multi-component and ".."; "." is allowed + * (read-only fstatat of the dir itself -- link_stat_at). */ + if (!*path || strchr(path, '/') + || (path[0] == '.' && path[1] == '.' && path[2] == '\0')) { errno = EINVAL; return -1; } From e0f2f52670b6dbc511123e3328def3dd01cb8b3d Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 22 Jun 2026 14:32:11 +1000 Subject: [PATCH 46/69] vfs: adapt the merged-in base changes to the VFS layer Linear-rebase counterpart of the two conflict resolutions made when the sec-fixes base was integrated (see the merge for reference): - Port 8a7e9a16's fixed-array dirstack into the relocated VFS code: vfs/vfs_internal.h (DS_MAXDEPTH + inline int fds[]) and vfs/dirstack.c (drop malloc/realloc/free; ENOMEM past the cap). 8a7e9a16 changed the dirstack in syscall.c, which this branch deleted, so the fix is re-expressed in vfs/. - Map the base's new F_XATTR ndx<0 guard (1d36a565) from the old do_chmod_at() to the unified vfs_chmod(VFS_AT_FDCWD, fname, ..., 0). Tree is byte-identical to the validated merge result. --- vfs/dirstack.c | 25 +++++++------------------ vfs/vfs_internal.h | 9 +++++++-- xattrs.c | 2 +- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/vfs/dirstack.c b/vfs/dirstack.c index ee2e1d4ef..7c73508de 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -121,26 +121,21 @@ static void ds_path_pop(struct dirstack *ds) *slash = '\0'; } -/* Initialise with `anchor` (which may be AT_FDCWD) as the un-owned base. */ +/* Initialise with `anchor` (which may be AT_FDCWD) as the un-owned base. + * Returns int for caller symmetry, but cannot fail (the fd array is inline). */ int ds_init(struct dirstack *ds, int anchor) { ds->abspath[0] = '\0'; - ds->cap = 16; - ds->fds = (int*)malloc(ds->cap * sizeof(int)); - if (!ds->fds) - return -1; ds->fds[0] = anchor; ds->top = 0; return 0; } -/* Close every pushed fd (but not the borrowed anchor at index 0) and free. */ +/* Close every pushed fd (but not the borrowed anchor at index 0). */ void ds_free(struct dirstack *ds) { while (ds->top > 0) close(ds->fds[ds->top--]); - free(ds->fds); - ds->fds = NULL; } int ds_cur(struct dirstack *ds) @@ -150,16 +145,10 @@ int ds_cur(struct dirstack *ds) static int ds_push(struct dirstack *ds, int fd) { - if (ds->top + 1 >= ds->cap) { - int ncap = ds->cap * 2; - int *n = (int*)realloc(ds->fds, ncap * sizeof(int)); - if (!n) { - close(fd); - errno = ENOMEM; - return -1; - } - ds->fds = n; - ds->cap = ncap; + if (ds->top + 1 >= DS_MAXDEPTH) { /* deeper than we'll hold open */ + close(fd); + errno = ENOMEM; + return -1; } ds->fds[++ds->top] = fd; return 0; diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index 78c9a292b..f7b8e440a 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -60,12 +60,17 @@ int abspath_excluded_by_module(const char *abspath, int name_is_dir, int is_oper #define SECURE_OPEN_MAXSYMLINKS 40 #endif +/* Max directory levels held open at once during a single resolve. The walk + * holds one fd per component, so depth is bounded by RLIMIT_NOFILE anyway; a + * fixed array (no malloc/realloc) keeps the stack simple and the static + * analyzer happy. Mirrors DPC_MAXDEPTH's fixed-cap approach. */ +#define DS_MAXDEPTH 1024 + /* The component-walk dirfd stack used by the secure resolver: a stack of the * open dirfds from the anchor (index 0, borrowed) down to the current dir. */ struct dirstack { - int *fds; /* fds[0] = anchor (borrowed); fds[top] = current dir */ + int fds[DS_MAXDEPTH]; /* fds[0] = anchor (borrowed); fds[top] = current dir */ int top; - int cap; /* Absolute path of fds[top], maintained as we descend/pop, for the * exclude-aware refusal (abspath_excluded_by_module). Empty unless the * caller seeds it with the anchor's absolute path; then a followed symlink diff --git a/xattrs.c b/xattrs.c index 46a63578b..b167eac20 100644 --- a/xattrs.c +++ b/xattrs.c @@ -1166,7 +1166,7 @@ int set_xattr(const char *fname, const struct file_struct *file, const char *fna if (fd >= 0) fchmod(fd, sxp->st.st_mode); else - do_chmod_at(fname, sxp->st.st_mode); + vfs_chmod(VFS_AT_FDCWD, fname, sxp->st.st_mode, 0); } return 0; } From fbfa40fe4d0426b3de23abc2fac73f2422f5be40 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Wed, 24 Jun 2026 11:21:59 +1000 Subject: [PATCH 47/69] vfs: port 37dbb263's operator-path mknod FIFO/socket fallback Linear-rebase counterpart of the round-2 merge resolution: 37dbb263 fixed do_mknod_at()'s operator branch in syscall.c (deleted on this branch) to fall back to mkfifoat() for a FIFO and EOPNOTSUPP for a nested socket when mknodat() can't make a special file on the BSDs/macOS/Solaris. Re-express it in the VFS_OPERATOR_PATH branch of vfs__mknod_secure() in vfs/mknod.c, mirroring the secure-relpath branch already in that function. Tree is byte-identical to the validated round-2 merge result. --- vfs/mknod.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/vfs/mknod.c b/vfs/mknod.c index d4d14e0d6..a6ffbfa35 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -126,6 +126,19 @@ static int vfs__mknod_secure(const char *pathname, mode_t mode, dev_t dev, int f if (dfd < 0) return -1; ret = mknodat(dfd, bname, mode, dev); + if (ret < 0) { + /* mknodat() can't make a FIFO/socket on the BSDs/macOS/ + * Solaris (EINVAL); retry race-safely on the held dirfd, + * mirroring the secure-relpath path below. Without this a + * FIFO backup to an operator --backup-dir fails there. */ +#ifdef HAVE_MKFIFOAT + if (S_ISFIFO(mode)) + ret = mkfifoat(dfd, bname, mode); + else +#endif + if (S_ISSOCK(mode)) + errno = EOPNOTSUPP; /* no dirfd-relative socket bind */ + } e = errno; close(dfd); errno = e; From 4099664250629dab5ea05ba3f6aaaedcc42581c0 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 27 Jun 2026 19:04:27 +1000 Subject: [PATCH 48/69] vfs: round-3 operator-path reconciliation (snap to merge oracle) The -X theirs rebase replayed the 47 VFS commits onto the new base, letting each VFS commit win its refactor on conflict; that drops the round-3 base changes that overlap the operator-path code (they were authored against the deleted syscall.c and the pre-vfs_flags model). This commit snaps those files byte-for-byte from the validated merge oracle (merge-reference-3 = 6afdd389), so the branch tip is tree-identical to the merge that built clean +/-xattr and passed the suite + root operator-path PoC tests. Reconciled: the syscall.c->vfs/ ports (secure_open/owner_walk/dirstack/rename/ link/chmod/chown/times/copy_file + vfs.h/vfs_internal.h), the set_file_attrs op_pin via ATTRS_OPERATOR_PATH (rsync.c/rsync.h + backup.c callers), the secure_basis_open/basis_link_stat operator branches (receiver.c/generator.c), the copy_file/gen_entry_copy_xattrs held-fd source reads, the change_dir/sender opt-out sites (util1.c/sender.c/clientserver.c), and Makefile.in. --- Makefile.in | 4 ++++ backup.c | 15 +++++---------- clientserver.c | 2 +- generator.c | 35 +++++++++++++++-------------------- receiver.c | 34 ++++++++++++++++++++++++++++------ rsync.c | 21 +++++++++++---------- rsync.h | 1 + sender.c | 2 +- util1.c | 6 +++--- vfs/chmod.c | 9 +++++++++ vfs/chown.c | 10 ++++++++++ vfs/copy_file.c | 44 ++++++++++++++++++++++++++++++++++---------- vfs/dirstack.c | 5 ++--- vfs/link.c | 28 +++++++++++++++++++++++++--- vfs/owner_walk.c | 18 ++++-------------- vfs/rename.c | 38 ++++++++++++++++++++++++++++++-------- vfs/secure_open.c | 11 ++++++++++- vfs/times.c | 25 +++++++++++++++++++++++++ vfs/vfs.h | 7 +++++++ vfs/vfs_internal.h | 2 +- 20 files changed, 226 insertions(+), 91 deletions(-) diff --git a/Makefile.in b/Makefile.in index d6c6c2182..0eaa16dd6 100644 --- a/Makefile.in +++ b/Makefile.in @@ -233,6 +233,10 @@ T_UNSAFE_OBJ = t_unsafe.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o l t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) +T_HASHTABLE_OVERFLOW_OBJ = t_hashtable_overflow.o hashtable.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o libvfs.a +t_hashtable_overflow$(EXEEXT): $(T_HASHTABLE_OVERFLOW_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_HASHTABLE_OVERFLOW_OBJ) $(LIBS) + T_IWILDMATCH_OBJ = t_iwildmatch.o lib/wildmatch.o t_iwildmatch$(EXEEXT): $(T_IWILDMATCH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_IWILDMATCH_OBJ) $(LIBS) diff --git a/backup.c b/backup.c index 5800093eb..dbf4f22f9 100644 --- a/backup.c +++ b/backup.c @@ -43,19 +43,14 @@ extern char *backup_dir; * backup_metadata_hardened() to tell the two -1 cases apart). */ int backup_metadata_hardened(void) { - return secure_relpath_active() && !symlink_optout_allowed(); + return vfs_relpath_active() && !vfs_symlink_optout_allowed(); } int backup_source_fd(const char *path) { #if defined AT_FDCWD && defined O_NOFOLLOW - if (backup_metadata_hardened() && path && *path) { - int save = operator_path_resolve, fd; - operator_path_resolve = 1; - fd = do_open_at(path, O_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, 0); - operator_path_resolve = save; - return fd; - } + if (backup_metadata_hardened() && path && *path) + return vfs_open_at(path, O_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, 0, VFS_OPERATOR_PATH); #endif return -1; } @@ -169,7 +164,7 @@ static BOOL copy_valid_path(const char *fname) close(bfd); } #endif - set_file_attrs(backup_dir_buf, file, NULL, NULL, 0); + set_file_attrs(backup_dir_buf, file, NULL, NULL, ATTRS_OPERATOR_PATH); unmake_file(file); } @@ -420,7 +415,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) save_preserve_xattrs = preserve_xattrs; preserve_xattrs = 0; - set_file_attrs(buf, file, NULL, fname, ATTRS_ACCURATE_TIME); + set_file_attrs(buf, file, NULL, fname, ATTRS_OPERATOR_PATH | ATTRS_ACCURATE_TIME); preserve_xattrs = save_preserve_xattrs; unmake_file(file); diff --git a/clientserver.c b/clientserver.c index 0e7008fa9..ff507992c 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1094,7 +1094,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char * to do with symlink munging, so a module configured with "munge symlinks = * false" must still get the secure-open path. */ use_secure_symlinks = am_daemon && (!am_chrooted || module_dirlen) - && !symlink_optout_allowed(); + && !vfs_symlink_optout_allowed(); if (gid_list.count) { gid_t *gid_array = gid_list.items; diff --git a/generator.c b/generator.c index 396864f27..702c73c7b 100644 --- a/generator.c +++ b/generator.c @@ -961,7 +961,6 @@ static int copy_altdest_file(const char *src, const char *dest, struct file_stru static int basis_link_stat(const char *path, STRUCT_STAT *stp) { extern int am_chrooted; - extern int operator_path_resolve; extern unsigned int module_dirlen; #if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY /* The basis dir (--link-dest/--compare-dest/--copy-dest) is an operator- @@ -990,7 +989,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) return r; } /* A non-chroot daemon serving an operator/peer alt-dest basis: resolve through - * the ownership walk with module-ROOT confinement (operator_path_resolve) so an + * the ownership walk with module-ROOT confinement (is_operator=1) so an * in-module symlink whose target lands OUTSIDE the module is refused -- the * basis then looks absent and the file transfers normally instead of being * stat'd/read/linked through the link (closes the --compare-dest=/E read @@ -1002,16 +1001,14 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) * and must keep the plain link_stat below (#915/#930). The leaf is taken * under the confined parent with O_NOFOLLOW/AT_SYMLINK_NOFOLLOW, so * --copy-links can't follow a leaf symlink out of the module. */ - if (am_daemon && !am_chrooted && path[0] == '/' && !symlink_optout_allowed()) { + if (am_daemon && !am_chrooted && path[0] == '/' && !vfs_symlink_optout_allowed()) { const char *leaf; - int dfd, e, save = operator_path_resolve; - operator_path_resolve = 1; - dfd = owner_walk_parent(path, &leaf); - operator_path_resolve = save; + int dfd, e; + dfd = vfs_owner_walk_parent(path, &leaf, 1); if (dfd < 0) return -1; if (am_root >= 0) { - int r = do_lstat_atfd(dfd, leaf, stp); + int r = vfs_lstat(dfd, leaf, stp, 0); e = errno; close(dfd); errno = e; @@ -1022,12 +1019,12 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) /* --fake-super: O_NOFOLLOW-open the held leaf (the daemon owns its * fake-super files) so the %stat xattr link_stat() would fold is * preserved while a leaf symlink is still refused. */ - int lfd = do_open_atfd(dfd, leaf, O_RDONLY | O_NOFOLLOW | O_NONBLOCK, 0); + int lfd = vfs_open_atfd(dfd, leaf, O_RDONLY | O_NOFOLLOW | O_NONBLOCK, 0); STRUCT_STAT xst; e = errno; close(dfd); if (lfd < 0) { errno = e; return -1; } - if (do_fstat(lfd, stp) < 0) { e = errno; close(lfd); errno = e; return -1; } + if (vfs_fstat(lfd, stp) < 0) { e = errno; close(lfd); errno = e; return -1; } if (get_stat_xattr(NULL, lfd, stp, &xst) == 0) *stp = xst; close(lfd); @@ -1035,7 +1032,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) } #else { - int r = do_lstat_atfd(dfd, leaf, stp); + int r = vfs_lstat(dfd, leaf, stp, 0); e = errno; close(dfd); errno = e; @@ -1044,7 +1041,7 @@ static int basis_link_stat(const char *path, STRUCT_STAT *stp) #endif } #endif - if (am_daemon && am_chrooted && module_dirlen && path[0] != '/' && !symlink_optout_allowed()) { + if (am_daemon && am_chrooted && module_dirlen && path[0] != '/' && !vfs_symlink_optout_allowed()) { const char *slash = strrchr(path, '/'); if (slash) { char dir[MAXPATHLEN]; @@ -1495,7 +1492,7 @@ static int gen_entry_rename(const char *opath, const char *npath, struct file_st static int gen_entry_copy_xattrs(const char *src, const char *fname, struct file_struct *file) { int dfd = vfs_cached_dirfd(fname, file); - int xfd = -1, ret; + int xfd = -1, sfd = -1, ret; if (dfd >= 0) { const char *slash = strrchr(fname, '/'); xfd = openat(dfd, slash ? slash + 1 : fname, @@ -1539,20 +1536,18 @@ static int gen_entry_copy_xattrs(const char *src, const char *fname, struct file * through the operator ownership walk. Refuse (don't path-read) when we are * meant to confine but can't pin; a non-hardened receiver path-reads (sfd<0). */ #if defined AT_FDCWD && defined O_NOFOLLOW - if (secure_relpath_active() && src && *src && !symlink_optout_allowed()) { + if (vfs_relpath_active() && src && *src && !vfs_symlink_optout_allowed()) { int odir = 0; #ifdef O_DIRECTORY - if (S_ISDIR(file->mode)) /* secure_relative_open rejects a dir leaf without this */ + if (S_ISDIR(file->mode)) /* vfs_resolve_open rejects a dir leaf without this */ odir = O_DIRECTORY; #endif if (src[0] != '/') - sfd = secure_relative_open(NULL, src, O_RDONLY | O_NOFOLLOW | odir, 0); + sfd = vfs_resolve_open(NULL, src, O_RDONLY | O_NOFOLLOW | odir, 0); else { - int save = operator_path_resolve, sdfd, e; + int sdfd, e; const char *leaf; - operator_path_resolve = 1; - sdfd = owner_walk_parent(src, &leaf); - operator_path_resolve = save; + sdfd = vfs_owner_walk_parent(src, &leaf, 1); if (sdfd >= 0) { sfd = openat(sdfd, leaf, O_RDONLY | O_NOFOLLOW | odir | O_NONBLOCK | O_NOCTTY | O_CLOEXEC); e = errno; close(sdfd); errno = e; diff --git a/receiver.c b/receiver.c index 27c038c7d..8fdc38621 100644 --- a/receiver.c +++ b/receiver.c @@ -103,6 +103,21 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags extern int am_daemon, am_chrooted; extern unsigned int module_dirlen; + /* "insecure links = yes": restore the 3.2.7 plain open so an operator/peer + * alt-dest basis follows symlinks like legacy rsync, the same opt-out the + * other daemon symlink sites honour. */ + if (vfs_symlink_optout_allowed()) { + if (basedir) { + char fullpath[MAXPATHLEN]; + if (pathjoin(fullpath, sizeof fullpath, basedir, relpath) >= sizeof fullpath) { + errno = ENAMETOOLONG; + return -1; + } + return vfs_open(fullpath, flags, mode); + } + return vfs_open(relpath, flags, mode); + } + /* A peer-supplied --partial-dir basis/staging path (is_operator, set by the * recv_files caller) may be absolute (module_dir-prefixed on a non-chroot * daemon) and traverse a symlink the vfs_resolve_open path can't confine: @@ -1053,12 +1068,18 @@ int recv_files(int f_in, int f_out, char *local_name) slash = strrchr(fnamecmp, '/'); fd1 = vfs_open_atfd(bdfd, slash ? slash + 1 : fnamecmp, O_RDONLY, 0); } else { - /* A --partial-dir basis is an operator/peer path: resolve it with - * the exclude-aware ownership walk so a symlinked partial-dir - * can't read (and feed back as delta) a file in an excluded - * subtree. */ + /* An operator-supplied basis -- a --partial-dir, or an + * alt-dest basedir (--copy-dest/--compare-dest/--link-dest) -- + * is a peer/operator path: resolve it with the exclude-aware + * ownership walk so a flipped foreign-owned parent symlink can't + * read (and feed back as delta) an out-of-tree / excluded file. + * The walk still allows the legitimate "../sibling" basis (#915) + * and the operator's own uid0/euid symlinks. A daemon keeps its + * stronger confinement branch in secure_basis_open(), so only + * route the alt-dest basedir read through the walk off-daemon. */ fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0, - fnamecmp_type == FNAMECMP_PARTIAL_DIR ? VFS_OPERATOR_PATH : 0); + ((basedir && !am_daemon) || fnamecmp_type == FNAMECMP_PARTIAL_DIR) ? VFS_OPERATOR_PATH : 0); + } } if (fnamecmp_type == FNAMECMP_PARTIAL_DIR && fd1 == -1) { @@ -1089,7 +1110,8 @@ int recv_files(int f_in, int f_out, char *local_name) basedir = basis_dir[0]; fnamecmp = fname; fnamecmp_type = FNAMECMP_BASIS_DIR_LOW; - fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0, 0); + fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0, + !am_daemon ? VFS_OPERATOR_PATH : 0); } } diff --git a/rsync.c b/rsync.c index d81e32d3a..749f0a844 100644 --- a/rsync.c +++ b/rsync.c @@ -37,7 +37,6 @@ extern int omit_dir_times; extern int omit_link_times; extern int am_root; extern int am_server; -extern int operator_path_resolve; extern int am_daemon; extern int am_sender; extern int am_receiver; @@ -607,16 +606,16 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, * operator owner-walk resolver and drive fchmod/fchown off that fd. A raced * symlink leaf makes the open fail, leaving op_leaf_fd == -1: the metadata op * is then refused, never redirected. --insecure-links opts back out (the - * resolver in do_open_at honours it), and a genuine symlink leaf (a symlink + * resolver in vfs_open_at honours it), and a genuine symlink leaf (a symlink * backup) keeps the existing l-variant path. */ /* Gate on the INTENDED type (new_mode), not the on-disk type (sxp->st): the * attacker controls the latter via the flip, and a dir component that has * just been flipped to a symlink must still take the pinned path so the * O_NOFOLLOW open refuses it -- otherwise the lchown would launder it. */ - op_pin = operator_path_resolve && dfd < 0 && !symlink_optout_allowed() + op_pin = (flags & ATTRS_OPERATOR_PATH) && dfd < 0 && !vfs_symlink_optout_allowed() && (S_ISREG(new_mode) || S_ISDIR(new_mode) || S_ISFIFO(new_mode)); if (op_pin) { - op_leaf_fd = do_open_at(fname, O_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, 0); + op_leaf_fd = vfs_open_at(fname, O_RDONLY | O_NONBLOCK | O_NOCTTY | O_CLOEXEC, 0, VFS_OPERATOR_PATH); /* When running as root (the uid-0 trust-laundering case) an O_RDONLY open * of a real owned reg/dir/fifo leaf never fails for permission reasons, so * ANY failure here means the leaf is being raced (a symlink refused by @@ -676,8 +675,10 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if (am_root >= 0) { uid_t uid = change_uid ? (uid_t)F_OWNER(file) : sxp->st.st_uid; gid_t gid = change_gid ? (gid_t)F_GROUP(file) : sxp->st.st_gid; - if ((dfd >= 0 ? vfs_lchown(dfd, leaf, uid, gid, 0) - : vfs_lchown(VFS_AT_FDCWD, fname, uid, gid, 0)) != 0) { + if ((op_leaf_fd >= 0 ? vfs_fchown(op_leaf_fd, uid, gid) + : op_refuse ? (errno = ELOOP, -1) + : dfd >= 0 ? vfs_lchown(dfd, leaf, uid, gid, 0) + : vfs_lchown(VFS_AT_FDCWD, fname, uid, gid, 0)) != 0) { /* We shouldn't have attempted to change uid * or gid unless have the privilege. */ rsyserr(FERROR_XFER, errno, "%s %s failed", @@ -768,7 +769,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, int ret; #ifdef HAVE_FUTIMENS if (op_leaf_fd >= 0) - ret = do_futimens(op_leaf_fd, &sx2.st); + ret = vfs_futimens(op_leaf_fd, &sx2.st); else #endif if (op_refuse) @@ -804,6 +805,8 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, #ifdef HAVE_CHMOD if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { int ret = am_root < 0 ? 0 + : op_leaf_fd >= 0 ? vfs_fchmod(op_leaf_fd, new_mode) + : op_refuse ? (errno = ELOOP, -1) : dfd >= 0 && !S_ISLNK(new_mode) ? vfs_chmod(dfd, leaf, new_mode, 0) : vfs_chmod(VFS_AT_FDCWD, fname, new_mode, 0); if (ret < 0) { @@ -903,10 +906,8 @@ int finish_transfer(const char *fname, const char *fnametmp, * dirfd, so resolve its metadata through the ownership walk (op_pin); a * flipped temp-dir parent then can't redirect the chmod/chown/times/etc. * (in-tree temps keep their held dirfd, so op_pin stays off there). */ - operator_path_resolve = 1; set_file_attrs(fnametmp, file, NULL, fnamecmp, - ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME); - operator_path_resolve = 0; + ATTRS_OPERATOR_PATH | (ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME)); /* move tmp file over real file */ if (DEBUG_GTE(RECV, 1)) diff --git a/rsync.h b/rsync.h index 1749be1f1..f5c286ed4 100644 --- a/rsync.h +++ b/rsync.h @@ -223,6 +223,7 @@ #define ATTRS_SKIP_MTIME (1<<1) #define ATTRS_ACCURATE_TIME (1<<2) #define ATTRS_SKIP_ATIME (1<<3) +#define ATTRS_OPERATOR_PATH (1<<4) /* fname is a cross-tree operator path: pin its leaf (op_pin) */ #define ATTRS_SKIP_CRTIME (1<<5) #define MSG_FLUSH 2 diff --git a/sender.c b/sender.c index 0c86165d7..d2e9dd606 100644 --- a/sender.c +++ b/sender.c @@ -85,7 +85,7 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, /* "insecure links = yes" / --insecure-links: restore the 3.2.7 plain re-stat * by declining the confined parent (errno=0 makes the caller use do_lstat). */ - if (symlink_optout_allowed()) { + if (vfs_symlink_optout_allowed()) { errno = 0; return -1; } diff --git a/util1.c b/util1.c index c43e910b1..7faa03003 100644 --- a/util1.c +++ b/util1.c @@ -895,7 +895,7 @@ int change_dir(const char *dir, int set_path_only) * branch still anchors at the operator-trusted * directory rather than wherever the kernel CWD * happens to be. */ - if (am_daemon && (!am_chrooted || module_dirlen) && !symlink_optout_allowed()) { + if (am_daemon && (!am_chrooted || module_dirlen) && !vfs_symlink_optout_allowed()) { const char *basedir = NULL; char prefix[MAXPATHLEN]; int dfd; @@ -917,11 +917,11 @@ int change_dir(const char *dir, int set_path_only) chdir_failed = fchdir(dfd) != 0; close(dfd); } - } else if (am_daemon && symlink_optout_allowed()) { + } else if (am_daemon && vfs_symlink_optout_allowed()) { /* "insecure links = yes": restore the 3.2.7 follow-any-symlink * traversal with a plain chdir to the accumulated path, the same * legacy behaviour the per-operation sites grant under the opt-out. */ - chdir_failed = chdir(curr_dir) != 0; + chdir_failed = chdir(vfs.curr_dir) != 0; } else if (!am_chrooted && !am_sender && !insecure_links) { /* Non-daemon receiver: confine the operator-named relative * destination like the absolute case above -- refuse a component diff --git a/vfs/chmod.c b/vfs/chmod.c index 9022c6c68..9a3d55cfd 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -249,4 +249,13 @@ int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags) return vfs__chmod_plain(path, mode); return vfs__chmod_secure(path, mode, flags); } + +/* Mode on an already-open fd (no path, no symlink to follow): the race-free + * counterpart for a pinned cross-tree operator leaf -- see set_file_attrs(). */ +int vfs_fchmod(int fd, mode_t mode) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return fchmod(fd, mode); +} #endif diff --git a/vfs/chown.c b/vfs/chown.c index c396574c9..b61960ef7 100644 --- a/vfs/chown.c +++ b/vfs/chown.c @@ -99,3 +99,13 @@ int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags) return vfs__lchown_plain(path, owner, group); return vfs__lchown_secure(path, owner, group, flags); } + +/* Mode/owner on an already-open fd (no path, no symlink to follow): the + * race-free way to set metadata on a cross-tree operator-path leaf that was + * pinned with O_NOFOLLOW. See set_file_attrs(). */ +int vfs_fchown(int fd, uid_t owner, gid_t group) +{ + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + return fchown(fd, owner, group); +} diff --git a/vfs/copy_file.c b/vfs/copy_file.c index 107072712..754739529 100644 --- a/vfs/copy_file.c +++ b/vfs/copy_file.c @@ -99,10 +99,32 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, * --copy-dest=cd where cd is a symlink to an outside directory) cannot * redirect the read to a file the attacker should not see. Plain * vfs_open_nofollow only refuses a final-component symlink; parents are - * still followed. (An absolute source is operator-trusted -- e.g. an - * absolutized basis dir -- and uses vfs_open_nofollow.) */ + * still followed. An ABSOLUTE source is an operator basis (e.g. an absolute + * --copy-dest): confine its parents via the ownership walk -- a foreign-owned + * parent symlink is refused, the operator's own dirs/uid0/euid symlinks + * followed -- so a flipped parent can't redirect the basis read out of tree. + * The walk runs with is_operator=1 (module-exclude enforced) and pins only + * the source side, leaving the dest open untouched -- this is why confining + * the source here does not re-open the copy_xattrs dest race the way wrapping + * the whole copy_altdest_file would. */ if (vfs_relpath_active() && source && *source && source[0] != '/') ifd = vfs_resolve_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + else if (vfs_relpath_active() && source && source[0] == '/' + && !vfs_symlink_optout_allowed()) { + int dfd, e; + const char *leaf; + dfd = vfs_owner_walk_parent(source, &leaf, 1); + if (dfd < 0) + ifd = -1; + else { + ifd = openat(dfd, leaf, O_RDONLY | O_NOFOLLOW); + e = errno; + close(dfd); + errno = e; + } + } +#endif else ifd = vfs_open_nofollow(source, O_RDONLY); if (ifd < 0) { @@ -161,11 +183,6 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, return -1; } - if (close(ifd) < 0) { - rsyserr(FWARNING, errno, "close failed on %s", - full_fname(source)); - } - /* Source file might have shrunk since we fstatted it. * Cut off any extra preallocated zeros from dest file. */ if (offset < prealloc_len) { @@ -183,17 +200,24 @@ int copy_file(const char *source, const char *dest, int tmpfilefd, mode_t mode, int save_errno = errno; rsyserr(FERROR, errno, "fsync failed on %s", full_fname(dest)); close(ofd); + close(ifd); /* ifd is held open until after the xattr copy below */ errno = save_errno; return -1; } #ifdef SUPPORT_XATTRS - /* Set xattrs through ofd while it's still held so a parent-symlink race - * can't redirect them onto a file outside the tree. */ + /* Read the source xattrs through the held source fd (ifd) and set them + * through ofd while both are still held, so a parent-symlink race can't + * redirect the read out of tree or the write onto a file outside it. */ if (preserve_xattrs) - copy_xattrs(source, dest, ofd); + copy_xattrs(source, ifd, dest, ofd); #endif + if (close(ifd) < 0) { + rsyserr(FWARNING, errno, "close failed on %s", + full_fname(source)); + } + if (close(ofd) < 0) { int save_errno = errno; rsyserr(FERROR_XFER, errno, "close failed on %s", full_fname(dest)); diff --git a/vfs/dirstack.c b/vfs/dirstack.c index 7c73508de..85c1003b6 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -52,9 +52,8 @@ int path_has_dotdot_component(const char *path) * name is not excluded may still resolve into an excluded IN-module subtree, * exactly as in stock rsync. The defense for a writable module is `munge * symlinks` (see rsyncd.conf(5)), not this walk. No-op unless we're a daemon. */ -int abspath_excluded_by_module(const char *abspath, int name_is_dir, int is_operator) +int abspath_excluded_by_module(const char *abspath, int is_operator) { - (void)name_is_dir; if (!am_daemon || !abspath || !vfs.module_dir) return 0; if (vfs.module_dirlen <= 1) /* module root is "/": nothing is outside */ @@ -193,7 +192,7 @@ int ds_descend(struct dirstack *ds, const char *part, int *hops) * symlink that redirected the walk into an excluded subtree). */ /* The strict resolver stays confined beneath the anchor (within the * module), so this never actually refuses; pass is_operator=0. */ - if (abspath_excluded_by_module(ds->abspath, 1, 0)) { + if (abspath_excluded_by_module(ds->abspath, 0)) { errno = ELOOP; return -1; } diff --git a/vfs/link.c b/vfs/link.c index aebe20ad6..be5f90fcd 100644 --- a/vfs/link.c +++ b/vfs/link.c @@ -100,9 +100,19 @@ int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) * "cd/target.txt", ...) escape the module. An absolute path uses * AT_FDCWD + the full path; each side is confined independently, so an * absolute source (e.g. an absolute --link-dest) cannot disable - * confinement of a relative destination. */ + * confinement of a relative destination. An absolute side is an operator + * path resolved via the ownership walk (foreign-owned parent symlink refused; + * --insecure-links keeps the legacy AT_FDCWD path). */ if (*old_path == '/') { - old_bname = old_path; +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (!vfs_symlink_optout_allowed()) { + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); + if (old_dfd < 0) + return -1; + old_owns = True; + } else +#endif + old_bname = old_path; } else if (old_slash) { old_dlen = old_slash - old_path; if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } @@ -118,7 +128,19 @@ int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) } if (*new_path == '/') { - new_bname = new_path; +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (!vfs_symlink_optout_allowed()) { + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); + if (new_dfd < 0) { + e = errno; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + new_owns = True; + } else +#endif + new_bname = new_path; } else if (new_slash) { new_dlen = new_slash - new_path; if (new_dlen >= sizeof new_dirpath) { diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 91e4256d1..4fdcc45e2 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -149,7 +149,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, 0, is_operator)) { + if (abspath_excluded_by_module(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -219,7 +219,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, S_ISDIR(lst.st_mode), is_operator)) { + if (abspath_excluded_by_module(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -243,7 +243,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, 1, is_operator)) { + if (abspath_excluded_by_module(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -340,22 +340,12 @@ int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) * based and not enforced here -- see abspath_excluded_by_module.) */ if (pabs[0]) { char leafabs[MAXPATHLEN]; - STRUCT_STAT lst; - int isdir = 0, absent = 0, refuse; - if (fstatat(dfd, *bname, &lst, AT_SYMLINK_NOFOLLOW) == 0) - isdir = S_ISDIR(lst.st_mode); - else - absent = 1; /* mkdir/rename target: type unknown yet */ if (snprintf(leafabs, sizeof leafabs, "%s/%s", pabs, *bname) >= (int)sizeof leafabs) { close(dfd); errno = ENAMETOOLONG; /* fail closed, never skip the check */ return -1; } - /* For an absent leaf the op may create a dir, so also test dir-only - * filter rules (a "/foo/" rule never matches a file). */ - refuse = abspath_excluded_by_module(leafabs, isdir, is_operator) - || (absent && abspath_excluded_by_module(leafabs, 1, is_operator)); - if (refuse) { + if (abspath_excluded_by_module(leafabs, is_operator)) { close(dfd); errno = ELOOP; return -1; diff --git a/vfs/rename.c b/vfs/rename.c index 03765a072..37b2ad51f 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -88,14 +88,24 @@ int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) old_slash = strrchr(old_path, '/'); new_slash = strrchr(new_path, '/'); - /* An absolute path uses AT_FDCWD with the full path; only a *relative* side - * is confined under the secure resolver. Confine each side independently: - * an absolute source (e.g. an absolute --temp-dir temp file) must NOT - * disable confinement of a relative destination, or finish_transfer's - * tmp->final rename re-resolves the dest from the path and a flipped parent - * symlink writes the file outside the tree (a symlink-race write escape). */ + /* Confine each side independently. A *relative* side is a transfer path, + * confined beneath the tree via vfs_resolve_open(). An *absolute* side is + * an operator path (an absolute --temp-dir/--partial-dir temp file): resolve + * its parent via the ownership walk so a flipped foreign-owned parent symlink + * can't redirect the rename out of tree, while still allowing the operator's + * own dirs/".."/uid0-or-euid symlinks. (--insecure-links keeps the legacy + * unconfined AT_FDCWD path.) Doing each side independently means an absolute + * source never disables confinement of a relative destination. */ if (*old_path == '/') { - old_bname = old_path; +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (!vfs_symlink_optout_allowed()) { + old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); + if (old_dfd < 0) + return -1; + old_owns = True; + } else +#endif + old_bname = old_path; } else if (old_slash) { old_dlen = old_slash - old_path; if (old_dlen >= sizeof old_dirpath) { @@ -114,7 +124,19 @@ int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) } if (*new_path == '/') { - new_bname = new_path; +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (!vfs_symlink_optout_allowed()) { + new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); + if (new_dfd < 0) { + e = errno; + if (old_owns) close(old_dfd); + errno = e; + return -1; + } + new_owns = True; + } else +#endif + new_bname = new_path; } else if (new_slash) { new_dlen = new_slash - new_path; if (new_dlen >= sizeof new_dirpath) { diff --git a/vfs/secure_open.c b/vfs/secure_open.c index 962e22222..cfb28ee4e 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -35,6 +35,15 @@ extern int open_noatime; * chroot confines the outer path, not the inner module. */ int vfs_relpath_active(void) { + /* The "insecure links" / --insecure-links opt-out restores the legacy + * follow-any-symlink behaviour uniformly, so it disables the secure + * resolver on the RECEIVER side too (not just the sender enumeration that + * already checks vfs_symlink_optout_allowed()). Without this an opted-out + * module still confined receiver writes/stats through a pre-existing + * in-module symlink -- failing to match the pre-3.4.3 behaviour the opt-out + * promises (documented in rsyncd.conf(5) "munge symlinks"/"insecure links"). */ + if (vfs_symlink_optout_allowed()) + return 0; if (am_daemon && am_chrooted && vfs.module_dirlen) return 1; return !am_chrooted && (am_daemon || !am_sender); @@ -130,7 +139,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) < (int)sizeof leafabs - && abspath_excluded_by_module(leafabs, 0, 0)) { + && abspath_excluded_by_module(leafabs, 0)) { errno = ELOOP; goto cleanup; } diff --git a/vfs/times.c b/vfs/times.c index 0ebd1d3b0..d88981bbb 100644 --- a/vfs/times.c +++ b/vfs/times.c @@ -353,3 +353,28 @@ int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp) #endif } #endif + +#ifdef HAVE_FUTIMENS +/* Set times on an already-open fd (the race-free counterpart for a pinned + * cross-tree operator leaf -- see set_file_attrs()). */ +int vfs_futimens(int fd, STRUCT_STAT *stp) +{ + struct timespec t[2]; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + t[0].tv_sec = stp->st_atime; +#ifdef ST_ATIME_NSEC + t[0].tv_nsec = stp->ST_ATIME_NSEC; +#else + t[0].tv_nsec = 0; +#endif + t[1].tv_sec = stp->st_mtime; +#ifdef ST_MTIME_NSEC + t[1].tv_nsec = stp->ST_MTIME_NSEC; +#else + t[1].tv_nsec = 0; +#endif + return futimens(fd, t); +} +#endif diff --git a/vfs/vfs.h b/vfs/vfs.h index e1ed1c0ec..7a3378930 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -144,6 +144,9 @@ int vfs_open_checklinks(const char *pathname); /* chmod (vfs/chmod.c). */ int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags); +#ifdef HAVE_CHMOD +int vfs_fchmod(int fd, mode_t mode); +#endif /* symlink/readlink (vfs/symlink.c). vfs_readlink is a function only in * fake-super builds; otherwise it is a macro -> readlink() (see rsync.h). */ @@ -170,6 +173,7 @@ int vfs_secure_mkstemp(char *template, mode_t perms, int operator_path); /* lchown (vfs/chown.c). */ int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags); +int vfs_fchown(int fd, uid_t owner, gid_t group); /* device/fifo/socket node creation (vfs/mknod.c). */ int vfs_mknod(int dirfd, const char *path, mode_t mode, dev_t dev, int flags); @@ -191,6 +195,9 @@ int robust_rename(const char *from, const char *to, const char *partialptr, int vfs_utimensat(const char *path, STRUCT_STAT *stp); int vfs_utimensat_at(const char *path, STRUCT_STAT *stp); int vfs_utimensat_atfd(int dfd, const char *name, STRUCT_STAT *stp); +#ifdef HAVE_FUTIMENS +int vfs_futimens(int fd, STRUCT_STAT *stp); +#endif int vfs_lutimes(const char *path, STRUCT_STAT *stp); int vfs_utimes(const char *path, STRUCT_STAT *stp); int vfs_utime(const char *path, STRUCT_STAT *stp); diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index f7b8e440a..a96bf604f 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -52,7 +52,7 @@ extern int module_id; /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); -int abspath_excluded_by_module(const char *abspath, int name_is_dir, int is_operator); +int abspath_excluded_by_module(const char *abspath, int is_operator); #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) From 139c305814a4f3d7db004e3cabc80c831a4281b3 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 27 Jun 2026 19:51:18 +1000 Subject: [PATCH 49/69] delete: confine the backup-tree unlink via the operator ownership walk A backup-tree delete (delete_item with DEL_FOR_BACKUP -- removing an existing leaf under an absolute --backup-dir before the new backup is placed) fell through del_unlink() to robust_unlink(fbuf, 0), which resolves the leaf's parent by path with no ownership walk. A local attacker who flips a backup-path parent to a symlink in that window could redirect the unlink outside the backup tree. The base confines this: make_backup() wraps make_backup_inner() in operator_path_resolve, so the same unlink reaches do_unlink_at()'s owner-walk branch. The VFS refactor replaced the global with explicit vfs_flags but left del_unlink() passing 0, dropping the confinement. Thread VFS_OPERATOR_PATH into del_unlink()'s path-based fallback when DEL_FOR_BACKUP is set (the exact cases that ran under the base's operator wrap); a held-dirfd delete is already confined and ignores it. The rmdir path keeps no owner-walk branch, matching base. --- delete.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/delete.c b/delete.c index 10e57debe..97b4d3d9a 100644 --- a/delete.c +++ b/delete.c @@ -68,13 +68,18 @@ static void del_chmod(const char *fbuf, mode_t mode) vfs_chmod(VFS_AT_FDCWD, fbuf, mode, 0); } -static int del_unlink(const char *fbuf) +/* vfs_flags carries VFS_OPERATOR_PATH for a backup-tree delete (DEL_FOR_BACKUP): + * the path-based fallback then resolves the leaf's parent via the ownership walk, + * matching the confinement the base gives this unlink under make_backup() (where + * the held dirfd is absent for a cross-tree --backup-dir leaf). A held-dirfd + * delete is already confined, so it ignores the flag. */ +static int del_unlink(const char *fbuf, int vfs_flags) { const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); if (dfd >= 0 && vfs_unlink(dfd, leaf, 0) == 0) return 0; - return robust_unlink(fbuf, 0); /* fall back (ETXTBSY retry, or not held) */ + return robust_unlink(fbuf, vfs_flags); /* fall back (ETXTBSY retry, or not held) */ } static inline int is_backup_file(char *fn) @@ -230,11 +235,11 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) ok = make_backup(fbuf, True); if (ok == 2) { what = "unlink"; - ok = del_unlink(fbuf) == 0; + ok = del_unlink(fbuf, (flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0) == 0; } } else { what = "unlink"; - ok = del_unlink(fbuf) == 0; + ok = del_unlink(fbuf, (flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0) == 0; } } From 62122367a4a4ad0888a13389107cf9f6cb07e780 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 27 Jun 2026 20:41:32 +1000 Subject: [PATCH 50/69] vfs: split two-path ops to per-operand policy flags (rename/link) vfs_rename_at() and vfs_link_at() took a single vfs_flags applied to BOTH operands, so VFS_OPERATOR_PATH (ownership walk: follow uid0/euid symlinks, refuse foreign) was applied to a transfer operand even when only one side was operator- supplied -- relaxing the secure receiver resolve that a transfer/file-list path should get. The default branch confined each side by absolute-vs-relative, but that missed a *relative* operator path (--backup-dir=bdir, --partial-dir=.rsync, relative --link-dest). Reported in the rsync-private PR #30 review. Take old_flags and new_flags separately and resolve each operand under its own policy via a shared vfs_twopath_side() helper (operator walk if the side is VFS_OPERATOR_PATH or absolute; secure receiver resolve if relative-with-slash; AT_FDCWD for a bare name). An operator basis/backup path on one side can no longer relax the other side's confinement. Callers now pass the correct per-side policy: - backup link/rename: source (live dest file) = transfer (0), backup target = operator. - receiver partial-dir rename: partialptr = operator, final dest = transfer. - hard_link_one / generator link-dest: basis source = operator (non-daemon), dest = transfer. - finish_transfer / gen_entry_rename / robust fallback: both transfer (0,0; default per-side). - robust_unlink ETXTBSY sibling rename: both share the caller's policy. Also drops the operator flag on backup's "just in case" robust_unlink of the transfer-side source. Behaviour-preserving for the operator side (the backup/partial-dir parents still owner-walk); it only tightens the transfer side back to the secure resolve. Builds +/-xattr; suite 210/0/66; root operator-path PoC tests all pass. Suggested-by: Zen Dodd --- backup.c | 13 +++-- generator.c | 8 ++- hlink.c | 5 +- receiver.c | 5 +- rsync.c | 4 +- t_rename_secure.c | 4 +- vfs/link.c | 125 ++++++------------------------------------ vfs/rename.c | 131 +++++++-------------------------------------- vfs/robust.c | 7 +-- vfs/secure_open.c | 58 ++++++++++++++++++++ vfs/vfs.h | 4 +- vfs/vfs_internal.h | 4 ++ 12 files changed, 130 insertions(+), 238 deletions(-) diff --git a/backup.c b/backup.c index dbf4f22f9..ddf5b9023 100644 --- a/backup.c +++ b/backup.c @@ -232,7 +232,11 @@ static inline int link_or_rename(const char *from, const char *to, if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode)) return 0; /* Use copy code. */ #endif - if (vfs_link_at(from, to, VFS_OPERATOR_PATH) == 0) { + /* from = the live dest file being backed up (a transfer path); to = the + * --backup-dir path (operator). Per-operand policy keeps the transfer + * source under the secure receiver resolve and only owner-walks the + * operator backup parent. */ + if (vfs_link_at(from, to, 0, VFS_OPERATOR_PATH) == 0) { if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: HLINK %s successful.\n", from); return 2; @@ -242,11 +246,12 @@ static inline int link_or_rename(const char *from, const char *to, return 0; } #endif - if (vfs_rename_at(from, to, VFS_OPERATOR_PATH) == 0) { + if (vfs_rename_at(from, to, 0, VFS_OPERATOR_PATH) == 0) { if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { /* If someone has hard-linked the file into the backup - * dir, rename() might return success but do nothing! */ - robust_unlink(from, VFS_OPERATOR_PATH); /* Just in case... */ + * dir, rename() might return success but do nothing! from is the + * transfer-side source, so unlink it under the secure resolve (0). */ + robust_unlink(from, 0); /* Just in case... */ } if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: RENAME %s successful.\n", from); diff --git a/generator.c b/generator.c index 702c73c7b..1585f7900 100644 --- a/generator.c +++ b/generator.c @@ -1261,7 +1261,11 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, && !IS_SPECIAL(file->mode) && !IS_DEVICE(file->mode) #endif && !S_ISDIR(file->mode)) { - if (vfs_link_at(cmpbuf, fname, 0) < 0) { + /* cmpbuf is the alt-dest (--link-dest) basis: for a non-daemon + * receiver it is an operator path (owner walk; matches the + * hard_link_one() path above and basis_link_stat's !am_daemon gate). + * fname is the transfer destination (secure receiver resolve). */ + if (vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) < 0) { rsyserr(FERROR_XFER, errno, "failed to hard-link %s with %s", cmpbuf, fname); @@ -1479,7 +1483,7 @@ static int gen_entry_rename(const char *opath, const char *npath, struct file_st const char *ns = strrchr(npath, '/'); return vfs_rename_atfd(odfd, os ? os + 1 : opath, ndfd, ns ? ns + 1 : npath); } - return vfs_rename_at(opath, npath, 0); + return vfs_rename_at(opath, npath, 0, 0); /* both live in the entry's dir (transfer) */ } #ifdef SUPPORT_XATTRS diff --git a/hlink.c b/hlink.c index 8646fb44b..114f3cc1d 100644 --- a/hlink.c +++ b/hlink.c @@ -475,7 +475,10 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, int hard_link_one(struct file_struct *file, const char *fname, const char *oldname, int terse, int vfs_flags) { - if (vfs_link_at(oldname, fname, vfs_flags) < 0) { + /* oldname is the link source (vfs_flags carries its policy -- VFS_OPERATOR_PATH + * for an alt-dest basis on a non-daemon receiver, else 0); fname is the + * transfer destination, always under the secure receiver resolve. */ + if (vfs_link_at(oldname, fname, vfs_flags, 0) < 0) { enum logcode code; if (terse) { if (!INFO_GTE(NAME, 1)) diff --git a/receiver.c b/receiver.c index 8fdc38621..8fe54b69b 100644 --- a/receiver.c +++ b/receiver.c @@ -702,8 +702,9 @@ static void handle_delayed_updates(char *local_name) * walk so a symlinked partial-dir can't move a file out of * an excluded subtree. */ int rret; - /* operator-supplied --partial-dir: resolve via the ownership walk. */ - rret = vfs_rename_at(partialptr, fname, VFS_OPERATOR_PATH); + /* partialptr is the operator-supplied --partial-dir source (owner + * walk); fname is the transfer destination (secure receiver resolve). */ + rret = vfs_rename_at(partialptr, fname, VFS_OPERATOR_PATH, 0); if (rret < 0) { rsyserr(FERROR_XFER, errno, "rename failed for %s (from %s)", diff --git a/rsync.c b/rsync.c index 749f0a844..7ec87b1a2 100644 --- a/rsync.c +++ b/rsync.c @@ -935,7 +935,9 @@ int finish_transfer(const char *fname, const char *fnametmp, ok_to_set_time ? ATTRS_ACCURATE_TIME : ATTRS_SKIP_MTIME | ATTRS_SKIP_ATIME | ATTRS_SKIP_CRTIME); if (temp_copy_name) { - if (vfs_rename_at(fnametmp, fname, 0) < 0) { + /* temp_copy_name and fname both live in the dest tree here; flag 0 lets + * vfs_twopath_side confine each side (absolute=owner-walk, relative=secure). */ + if (vfs_rename_at(fnametmp, fname, 0, 0) < 0) { rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\"", full_fname(fnametmp), fname); return 0; diff --git a/t_rename_secure.c b/t_rename_secure.c index ebadcdd28..667ccccfc 100644 --- a/t_rename_secure.c +++ b/t_rename_secure.c @@ -37,7 +37,7 @@ static int vulnerable_mixed_rename_at(const char *old_path, const char *new_path if (!old_slash || !new_slash) return vfs_rename(old_path, new_path); - return vfs_rename_at(old_path, new_path, 0); + return vfs_rename_at(old_path, new_path, 0, 0); } #endif @@ -64,7 +64,7 @@ static void check_rename(const char *label, const char *old_path, int saved_errno; errno = 0; - rc = vfs_rename_at(old_path, new_path, 0); + rc = vfs_rename_at(old_path, new_path, 0, 0); saved_errno = errno; got_ok = rc == 0; diff --git a/vfs/link.c b/vfs/link.c index be5f90fcd..14e691e5d 100644 --- a/vfs/link.c +++ b/vfs/link.c @@ -38,22 +38,22 @@ int vfs_link(const char *old_path, const char *new_path) the module, or hard-link an outside file into the module (read disclosure). - Defence: open each parent under vfs_resolve_open() and use - linkat() between the two dirfds, reusing one when the parents - match. flags=0 matches the existing vfs_link() (don't follow a - symbolic-link old_path). Only available on systems with linkat(); - pre-AT_FDCWD systems fall through to vfs_link(). + Defence: resolve each path's parent under its OWN policy and linkat() + between the two dirfds. old_flags / new_flags are the per-operand policy + (VFS_OPERATOR_PATH for an operator operand -- e.g. a --link-dest/--backup-dir + basis; 0 for a transfer path), so an operator basis on one side can't relax the + transfer-path confinement of the other -- see vfs_twopath_side(). flags=0 to + linkat() matches the existing vfs_link() (don't follow a symlink old_path). + Only available on systems with linkat(); pre-AT_FDCWD systems fall through. */ -int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) +int vfs_link_at(const char *old_path, const char *new_path, int old_flags, int new_flags) { #if defined AT_FDCWD && defined HAVE_LINKAT char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; - const char *old_slash, *new_slash; int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; BOOL old_owns = False, new_owns = False; int ret, e; - size_t old_dlen = 0, new_dlen = 0; if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; @@ -64,109 +64,15 @@ int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) if (!old_path || !*old_path || !new_path || !*new_path) return vfs_link(old_path, new_path); -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path (a --backup-dir/--link-dest side): resolve each - * parent via the ownership walk (follow uid0/euid symlinks, refuse others). */ - if (vfs_flags & VFS_OPERATOR_PATH) { - if (vfs_symlink_optout_allowed()) - return vfs_link(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); - if (old_dfd < 0) - return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); - if (new_dfd < 0) { - e = errno; - close(old_dfd); - errno = e; - return -1; - } - ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); + if (vfs_twopath_side(old_path, old_flags, &old_bname, &old_dfd, &old_owns, + old_dirpath, sizeof old_dirpath) < 0) + return -1; + if (vfs_twopath_side(new_path, new_flags, &new_bname, &new_dfd, &new_owns, + new_dirpath, sizeof new_dirpath) < 0) { e = errno; - close(new_dfd); - close(old_dfd); + if (old_owns) close(old_dfd); errno = e; - return ret; - } -#endif - - old_slash = strrchr(old_path, '/'); - new_slash = strrchr(new_path, '/'); - - /* Resolve each path's parent dir independently. A path without a - * slash lives in CWD (AT_FDCWD), no parent open required. A path - * with a slash needs vfs_resolve_open to confine its parent - * resolution -- otherwise a parent symlink (e.g. --link-dest=cd - * where cd -> /outside) lets the kernel-level linkat(AT_FDCWD, - * "cd/target.txt", ...) escape the module. An absolute path uses - * AT_FDCWD + the full path; each side is confined independently, so an - * absolute source (e.g. an absolute --link-dest) cannot disable - * confinement of a relative destination. An absolute side is an operator - * path resolved via the ownership walk (foreign-owned parent symlink refused; - * --insecure-links keeps the legacy AT_FDCWD path). */ - if (*old_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!vfs_symlink_optout_allowed()) { - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); - if (old_dfd < 0) - return -1; - old_owns = True; - } else -#endif - old_bname = old_path; - } else if (old_slash) { - old_dlen = old_slash - old_path; - if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } - memcpy(old_dirpath, old_path, old_dlen); - old_dirpath[old_dlen] = '\0'; - old_bname = old_slash + 1; - old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (old_dfd < 0) - return -1; - old_owns = True; - } else { - old_bname = old_path; - } - - if (*new_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!vfs_symlink_optout_allowed()) { - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } else -#endif - new_bname = new_path; - } else if (new_slash) { - new_dlen = new_slash - new_path; - if (new_dlen >= sizeof new_dirpath) { - e = ENAMETOOLONG; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - memcpy(new_dirpath, new_path, new_dlen); - new_dirpath[new_dlen] = '\0'; - new_bname = new_slash + 1; - if (old_owns && old_dlen == new_dlen - && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { - new_dfd = old_dfd; - } else { - new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } - } else { - new_bname = new_path; + return -1; } ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); @@ -178,6 +84,7 @@ int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags) errno = e; return ret; #else + (void)old_flags; (void)new_flags; return vfs_link(old_path, new_path); #endif } diff --git a/vfs/rename.c b/vfs/rename.c index 37b2ad51f..daec7f2b4 100644 --- a/vfs/rename.c +++ b/vfs/rename.c @@ -30,25 +30,26 @@ int vfs_rename(const char *old_path, const char *new_path) rename() is the central tmp -> final operation in rsync; if either the source or the destination has an attacker-substituted symlink in one of its parent components, the rename can publish or vanish files - outside the module. Defence: open the parent of *each* path under - vfs_resolve_open() and use renameat() against the resulting - dirfds. When old_path and new_path share the same parent (the common - case -- tmp file living next to its final name), we reuse the same - dirfd for both sides. + outside the module. Defence: resolve the parent of *each* path under its + OWN policy and renameat() between the resulting dirfds. + + old_flags / new_flags are the per-operand policy (VFS_OPERATOR_PATH for an + operator-supplied operand -- a --backup-dir/--partial-dir/--temp-dir path; 0 + for a transfer path). Passing them separately means an operator operand on one + side cannot relax (owner-walk) the transfer-path confinement of the other side + -- see vfs_twopath_side() for the per-side resolution. Falls through to vfs_rename() in dry-run, non-daemon, chrooted and - absolute-path cases, identical to the other do_*_at() wrappers. + --insecure-links cases, identical to the other *_at() wrappers. */ -int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) +int vfs_rename_at(const char *old_path, const char *new_path, int old_flags, int new_flags) { #ifdef AT_FDCWD char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; const char *old_bname, *new_bname; - const char *old_slash, *new_slash; int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; BOOL old_owns = False, new_owns = False; - int ret = -1, e; - size_t old_dlen = 0, new_dlen = 0; + int ret, e; if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; @@ -59,110 +60,15 @@ int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) if (!old_path || !*old_path || !new_path || !*new_path) return vfs_rename(old_path, new_path); -#if defined O_NOFOLLOW && defined O_DIRECTORY - /* Operator-supplied path (e.g. a --backup-dir destination or a --temp-dir - * source): resolve each side's parent via the ownership walk (follow - * uid0/euid symlinks, refuse others; absolute and relative alike). */ - if (vfs_flags & VFS_OPERATOR_PATH) { - if (vfs_symlink_optout_allowed()) - return vfs_rename(old_path, new_path); - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); - if (old_dfd < 0) - return -1; - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); - if (new_dfd < 0) { - e = errno; - close(old_dfd); - errno = e; - return -1; - } - ret = renameat(old_dfd, old_bname, new_dfd, new_bname); + if (vfs_twopath_side(old_path, old_flags, &old_bname, &old_dfd, &old_owns, + old_dirpath, sizeof old_dirpath) < 0) + return -1; + if (vfs_twopath_side(new_path, new_flags, &new_bname, &new_dfd, &new_owns, + new_dirpath, sizeof new_dirpath) < 0) { e = errno; - close(new_dfd); - close(old_dfd); + if (old_owns) close(old_dfd); errno = e; - return ret; - } -#endif - - old_slash = strrchr(old_path, '/'); - new_slash = strrchr(new_path, '/'); - - /* Confine each side independently. A *relative* side is a transfer path, - * confined beneath the tree via vfs_resolve_open(). An *absolute* side is - * an operator path (an absolute --temp-dir/--partial-dir temp file): resolve - * its parent via the ownership walk so a flipped foreign-owned parent symlink - * can't redirect the rename out of tree, while still allowing the operator's - * own dirs/".."/uid0-or-euid symlinks. (--insecure-links keeps the legacy - * unconfined AT_FDCWD path.) Doing each side independently means an absolute - * source never disables confinement of a relative destination. */ - if (*old_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!vfs_symlink_optout_allowed()) { - old_dfd = vfs_owner_walk_parent(old_path, &old_bname, 1); - if (old_dfd < 0) - return -1; - old_owns = True; - } else -#endif - old_bname = old_path; - } else if (old_slash) { - old_dlen = old_slash - old_path; - if (old_dlen >= sizeof old_dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(old_dirpath, old_path, old_dlen); - old_dirpath[old_dlen] = '\0'; - old_bname = old_slash + 1; - old_dfd = vfs_resolve_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (old_dfd < 0) - return -1; - old_owns = True; - } else { - old_bname = old_path; - } - - if (*new_path == '/') { -#if defined O_NOFOLLOW && defined O_DIRECTORY - if (!vfs_symlink_optout_allowed()) { - new_dfd = vfs_owner_walk_parent(new_path, &new_bname, 1); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } else -#endif - new_bname = new_path; - } else if (new_slash) { - new_dlen = new_slash - new_path; - if (new_dlen >= sizeof new_dirpath) { - e = ENAMETOOLONG; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - memcpy(new_dirpath, new_path, new_dlen); - new_dirpath[new_dlen] = '\0'; - new_bname = new_slash + 1; - if (old_owns && old_dlen == new_dlen - && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { - new_dfd = old_dfd; - } else { - new_dfd = vfs_resolve_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); - if (new_dfd < 0) { - e = errno; - if (old_owns) close(old_dfd); - errno = e; - return -1; - } - new_owns = True; - } - } else { - new_bname = new_path; + return -1; } ret = renameat(old_dfd, old_bname, new_dfd, new_bname); @@ -174,6 +80,7 @@ int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags) errno = e; return ret; #else + (void)old_flags; (void)new_flags; return vfs_rename(old_path, new_path); #endif } diff --git a/vfs/robust.c b/vfs/robust.c index a955b1ec6..f0af56521 100644 --- a/vfs/robust.c +++ b/vfs/robust.c @@ -70,8 +70,9 @@ int robust_unlink(const char *fname, int vfs_flags) fname, path); } - /* maybe we should return rename()'s exit status? Nah. */ - if (vfs_rename_at(fname, path, vfs_flags) != 0) { + /* maybe we should return rename()'s exit status? Nah. path is a sibling of + * fname in the same parent, so both sides share fname's policy (vfs_flags). */ + if (vfs_rename_at(fname, path, vfs_flags, vfs_flags) != 0) { errno = ETXTBSY; return -1; } @@ -104,7 +105,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, const char *ns = strrchr(to, '/'); rr = vfs_rename_atfd(ofd, os ? os + 1 : from, nfd, ns ? ns + 1 : to); } else - rr = vfs_rename_at(from, to, 0); + rr = vfs_rename_at(from, to, 0, 0); if (rr == 0) return 0; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index cfb28ee4e..c8ec13891 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -365,3 +365,61 @@ int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mo return secure_walk_at(anchor_fd, NULL, relpath, flags, mode, &hops); #endif } + +/* Resolve ONE operand of a two-path op (rename/link) to a parent dirfd + leaf, + * per that operand's OWN policy -- so a two-path op can confine each side + * independently (an operator basis/backup path on one side must not relax the + * transfer-path confinement of the other). Policy: + * - side_flags & VFS_OPERATOR_PATH, or an absolute path: ownership walk + * (follow uid0/euid symlinks, refuse foreign; module-exclude enforced). + * - a relative path with a slash: secure receiver resolve of the parent. + * - a bare name: AT_FDCWD + the name. + * Sets *bname and *dfd_out (a dirfd or AT_FDCWD), and *owns True when *dfd_out + * must be closed by the caller. dirbuf (>= MAXPATHLEN) is scratch for a parent + * path. Returns 0 on success, -1 (errno set) on error. The caller must already + * have confirmed vfs_relpath_active() (otherwise it does the plain libc op). */ +int vfs_twopath_side(const char *path, int side_flags, const char **bname, + int *dfd_out, BOOL *owns, char *dirbuf, size_t dirbufsz) +{ + *owns = False; +#ifdef AT_FDCWD + const char *slash = strrchr(path, '/'); + size_t dlen; + +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (((side_flags & VFS_OPERATOR_PATH) || *path == '/') + && !vfs_symlink_optout_allowed()) { + int dfd = vfs_owner_walk_parent(path, bname, 1); + if (dfd < 0) + return -1; + *dfd_out = dfd; + *owns = True; + return 0; + } +#endif + if (*path == '/' || !slash) { + /* absolute under --insecure-links, or a bare name: AT_FDCWD + path. */ + *bname = path; + *dfd_out = AT_FDCWD; + return 0; + } + dlen = (size_t)(slash - path); + if (dlen >= dirbufsz) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirbuf, path, dlen); + dirbuf[dlen] = '\0'; + *bname = slash + 1; + *dfd_out = vfs_resolve_open(NULL, dirbuf, O_RDONLY | O_DIRECTORY, 0); + if (*dfd_out < 0) + return -1; + *owns = True; + return 0; +#else + (void)side_flags; (void)dirbuf; (void)dirbufsz; + *bname = path; + *dfd_out = -1; + return 0; +#endif +} diff --git a/vfs/vfs.h b/vfs/vfs.h index 7a3378930..fbe694a43 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -129,7 +129,7 @@ int vfs_fstat(int fd, STRUCT_STAT *st); /* rename (vfs/rename.c). */ int vfs_rename(const char *old_path, const char *new_path); -int vfs_rename_at(const char *old_path, const char *new_path, int vfs_flags); +int vfs_rename_at(const char *old_path, const char *new_path, int old_flags, int new_flags); int vfs_rename_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name); /* unlink and rmdir (vfs/unlink.c). */ @@ -158,7 +158,7 @@ ssize_t vfs_readlink(const char *path, char *buf, size_t bufsiz); /* hard links (vfs/link.c). */ int vfs_link(const char *old_path, const char *new_path); -int vfs_link_at(const char *old_path, const char *new_path, int vfs_flags); +int vfs_link_at(const char *old_path, const char *new_path, int old_flags, int new_flags); int vfs_link_atfd(int old_dfd, const char *old_name, int new_dfd, const char *new_name, int flags); /* mkdir / mkstemp and the trim_trailing_slashes path helper (vfs/mkdir.c). */ diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index a96bf604f..aa0046d7b 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -54,6 +54,10 @@ extern int module_id; int path_has_dotdot_component(const char *path); int abspath_excluded_by_module(const char *abspath, int is_operator); +/* Per-operand parent resolver for the two-path ops (vfs_rename_at/vfs_link_at). */ +int vfs_twopath_side(const char *path, int side_flags, const char **bname, + int *dfd_out, BOOL *owns, char *dirbuf, size_t dirbufsz); + #if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) #ifndef SECURE_OPEN_MAXSYMLINKS From 955115c5a5d4ba4cc71ef7e3df9ddcb66413bdbb Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 27 Jun 2026 20:51:11 +1000 Subject: [PATCH 51/69] testsuite: per-operand policy regression for vfs_rename_at (PR #30) Extend the t_rename_secure harness with PS-refuse/PS-follow: rename to the SAME operator-owned escaping symlink path (oplink -> ../trap) with the SAME operator old-side flag, differing ONLY in the new-side flag. The ownership walk (operator) follows the operator's own uid0/euid symlink; the secure receiver resolve (transfer, flag 0) refuses it because it leaves the cwd anchor. PS-refuse must be rejected and PS-follow must succeed -- proving the new side's confinement is independent of the old side's policy. Deterministic, non-root, no race. Verified RED on the old whole-call behaviour (emulated by OR-ing both operands' flags, the new side then follows oplink and escapes to ../trap) and GREEN with the per-operand split. The driver adds the oplink fixture and the per-side source files. Suggested-by: Zen Dodd --- t_rename_secure.c | 56 +++++++++++++++++++ .../rename-mixed-parent-symlink-race_test.py | 8 +++ 2 files changed, 64 insertions(+) diff --git a/t_rename_secure.c b/t_rename_secure.c index 667ccccfc..3de047fbb 100644 --- a/t_rename_secure.c +++ b/t_rename_secure.c @@ -79,6 +79,30 @@ static void check_rename(const char *label, const char *old_path, label, old_path, new_path, expect_ok ? "succeeded" : "rejected"); } +/* Like check_rename() but with explicit per-operand policy flags, for the + * two-path per-side split (PR #30). */ +static void check_rename_flags(const char *label, const char *old_path, + const char *new_path, int old_flags, int new_flags, + int expect_ok) +{ + int rc, got_ok, saved_errno; + + errno = 0; + rc = vfs_rename_at(old_path, new_path, old_flags, new_flags); + saved_errno = errno; + got_ok = rc == 0; + + if (got_ok != expect_ok) { + fprintf(stderr, "FAIL [%s]: rename %s -> %s (of=%d nf=%d) rc=%d errno=%d (%s), expected %s\n", + label, old_path, new_path, old_flags, new_flags, rc, saved_errno, + strerror(saved_errno), expect_ok ? "success" : "rejection"); + errs++; + return; + } + fprintf(stderr, "OK [%s]: rename %s -> %s %s\n", + label, old_path, new_path, expect_ok ? "succeeded" : "rejected"); +} + static void check_vulnerable_rename(const char *label, const char *old_path, const char *new_path) { @@ -199,6 +223,38 @@ int main(int argc, char **argv) check_exists("F source consumed", "top-old", 0); check_exists("F destination created", "top-new", 1); + /* Per-operand policy split (PR #30): the NEW side's policy must be independent + * of the OLD side's. oplink is a caller-owned (uid0/euid) symlink that ESCAPES + * the tree (-> ../trap): the ownership walk (operator policy) follows the + * operator's own symlink, but the secure receiver resolve (transfer, flag 0) + * refuses it because it leaves the cwd anchor. PS-refuse and PS-follow rename + * to the SAME oplink/ path with the SAME operator old side, differing ONLY in + * the new-side flag -- so the per-side flag, not a whole-call flag, decides. + * The old single-flag API applied VFS_OPERATOR_PATH to both operands, so it + * would have followed oplink in PS-refuse too (PS-refuse is RED on that code). + * Run non-daemon (the regime where an operator basis/backup path legitimately + * carries the operator's own uid0/euid symlinks). */ + { + struct stat lst; + if (lstat("oplink", &lst) == 0 && S_ISLNK(lst.st_mode)) { + int save_daemon = am_daemon; + am_daemon = 0; + + check_rename_flags("PS-refuse: new side transfer refuses an escaping owned symlink", + "realdir/perside-src2", "oplink/tr-out", + VFS_OPERATOR_PATH, 0, 0); + check_exists("PS-refuse out-of-tree dest absent", "../trap/tr-out", 0); + check_exists("PS-refuse source preserved", "realdir/perside-src2", 1); + + check_rename_flags("PS-follow: new side operator follows the same owned symlink", + "realdir/perside-src3", "oplink/op-out", + VFS_OPERATOR_PATH, VFS_OPERATOR_PATH, 1); + check_exists("PS-follow out-of-tree dest created (operator's own symlink)", "../trap/op-out", 1); + + am_daemon = save_daemon; + } + } + if (errs) fprintf(stderr, "%d failure(s)\n", errs); return errs ? 1 : 0; diff --git a/testsuite/rename-mixed-parent-symlink-race_test.py b/testsuite/rename-mixed-parent-symlink-race_test.py index f5c25dd34..f7fa5ffe6 100644 --- a/testsuite/rename-mixed-parent-symlink-race_test.py +++ b/testsuite/rename-mixed-parent-symlink-race_test.py @@ -31,6 +31,14 @@ (mod / 'top-old').write_text("top-old\n") os.symlink('../trap', mod / 'escape_link') +# Per-operand policy split (PR #30): a caller-owned symlink that ESCAPES the tree +# (-> ../trap). The ownership walk (operator) follows the operator's own symlink; +# the secure receiver resolve (transfer, flag 0) refuses it. The harness +# PS-refuse/PS-follow checks rename to oplink/ under each new-side policy. +os.symlink('../trap', mod / 'oplink') +for n in ('perside-src2', 'perside-src3'): + (mod / 'realdir' / n).write_text(n + "\n") + proc = subprocess.run([str(TOOLDIR / 't_rename_secure'), str(mod)]) if proc.returncode == 77: test_skipped("t_rename_secure skipped") From eab19ec7f922dbddf63b9896fe23677d7533eabb Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sat, 27 Jun 2026 21:24:21 +1000 Subject: [PATCH 52/69] rsync: never path-resolve a confined receiver's xattr/ACL write (copy-xattrs race) set_file_attrs() pins the entry via the cached held dir fd and drives the xattr /ACL ops off that fd (fsetxattr). But when the pin missed -- vfs_cached_dirfd() returns -1 (its dir isn't the held one, or the path is deeper than the dirfd cache), or the leaf openat() loses a race -- held_fd stayed -1 and set_stat_xattr()/set_xattr()/get_acl_fdat()/set_acl_fdat() fell through to the path-based branch (sys_lsetxattr(fname,...)). Unlike the chmod/chown/times path wrappers (which secure-resolve), that raw lsetxattr re-resolves the parent, so a concurrent flip of a dest parent component to a symlink->outside lands the xattr OUTSIDE the destination tree (the intermittent copy-xattrs-symlink-race escape that surfaces under -j load, which widens the open->setxattr window). Re-pin through the secure resolver when the cached pin misses on a confined, non-operator receiver path, so the xattr/ACL ops always use a confined fd -- NOT a raw path lsetxattr; if the re-pin also fails (a genuinely raced parent/leaf symlink) skip the path-based ops (xattr_refuse) rather than redirecting them. The re-pin passes O_DIRECTORY for a directory leaf (the secure resolver refuses a bare dir open with EISDIR), so dir xattrs/ACLs are still preserved on a cache miss. Apply the same re-pin/refuse to gen_entry_copy_xattrs() (the dir xattr copy), whose dfd<0 path likewise fell to copy_xattrs() with dest_fd==-1. chmod/chown/times are unchanged (already confined via their *at wrappers); operator paths keep op_pin/op_refuse. Confirmed: under heavy parallel load the dangerous lsetxattr fallback was reached 52+ times before and 0 times after; dir xattrs preserved; suite 210/0/66, root metadata PoC tests pass, builds +/-xattr. --- generator.c | 14 +++++++------- rsync.c | 32 ++++++++++++++------------------ 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/generator.c b/generator.c index 1585f7900..981fc2d4f 100644 --- a/generator.c +++ b/generator.c @@ -1513,18 +1513,18 @@ static int gen_entry_copy_xattrs(const char *src, const char *fname, struct file } } #if defined AT_FDCWD && defined O_NOFOLLOW - else if (secure_relpath_active()) { - /* No cached parent dirfd (a path deeper than the dirfd cache, or a raced - * parent) but we must confine: re-pin the dest leaf through the secure - * resolver so copy_xattrs uses fsetxattr, not a path-based lsetxattr a - * flipped parent could redirect out of tree. A raced parent/leaf makes - * this fail -> refuse rather than path-write. */ + else if (vfs_relpath_active()) { + /* No cached parent dirfd (e.g. a path deeper than the dirfd cache, or a + * raced parent) but we must confine: re-pin the dest leaf through the + * secure resolver so copy_xattrs uses fsetxattr, not a path-based + * lsetxattr a flipped parent could redirect out of tree. A raced + * parent/leaf makes this fail -> refuse rather than path-write. */ int odir = 0; # ifdef O_DIRECTORY if (S_ISDIR(file->mode)) odir = O_DIRECTORY; # endif - xfd = secure_relative_open(NULL, fname, + xfd = vfs_resolve_open(NULL, fname, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC | odir, 0); if (xfd < 0) { rsyserr(FERROR_XFER, errno, diff --git a/rsync.c b/rsync.c index 7ec87b1a2..5a3990c26 100644 --- a/rsync.c +++ b/rsync.c @@ -568,30 +568,26 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, )) held_fd = openat(dfd, leaf, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC); - /* If the held-fd pin above missed (no cached dir fd -- a path deeper than the - * dirfd cache, or a raced leaf) but we are a confined receiver on a - * non-operator path, re-pin the leaf through the secure resolver so the - * xattr/ACL ops below drive fsetxattr off a confined fd -- NOT a raw path-based - * lsetxattr, which re-resolves the parent and lets a flipped dest/sub symlink - * land the xattr OUTSIDE the tree (copy-xattrs-symlink-race). If the secure - * re-pin also fails (a genuinely raced parent/leaf symlink), held_fd stays -1 - * and xattr_refuse skips the path-based ops rather than redirecting them. - * (chmod/chown/times stay safe via their secure path wrappers; operator paths - * use op_pin/op_refuse below.) */ - if (held_fd < 0 && !operator_path_resolve && secure_relpath_active() + /* If the held-fd pin above missed (no cached dir fd, or a raced leaf) but we + * are a confined receiver on a non-operator path, re-pin the leaf through the + * secure resolver so the xattr/ACL ops below drive fsetxattr off a confined fd + * -- NOT a raw path-based lsetxattr, which re-resolves the parent and lets a + * flipped dest/sub symlink land the xattr OUTSIDE the tree (copy-xattrs- + * symlink-race). A confined receiver normally always has the cached pin; a + * miss here is a raced parent/leaf. If the secure re-pin also fails (the + * parent/leaf is a symlink), held_fd stays -1 and xattr_refuse below skips the + * path-based ops rather than redirecting them. (chmod/chown/times stay safe + * via their secure path wrappers; operator paths use op_pin/op_refuse.) */ + if (held_fd < 0 && !(flags & ATTRS_OPERATOR_PATH) && vfs_relpath_active() && (S_ISREG(sxp->st.st_mode) || S_ISDIR(sxp->st.st_mode) || S_ISFIFO(sxp->st.st_mode)) && (preserve_xattrs || am_root < 0 # ifdef SUPPORT_ACLS || (preserve_acls && am_root >= 0) # endif )) { - int odir = 0; -# ifdef O_DIRECTORY - if (S_ISDIR(sxp->st.st_mode)) - odir = O_DIRECTORY; -# endif - held_fd = secure_relative_open(NULL, fname, - O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC | odir, 0); + held_fd = vfs_resolve_open(NULL, fname, + O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC + | (S_ISDIR(sxp->st.st_mode) ? O_DIRECTORY : 0), 0); if (held_fd < 0 && strchr(fname, '/')) xattr_refuse = 1; } From 5210c5fd53596519e283e6d558992055bcf090fc Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 28 Jun 2026 10:06:51 +1000 Subject: [PATCH 53/69] vfs: review fixes cleanups from review comments by Sam James. Thanks! --- vfs/chmod.c | 2 +- vfs/copy_file.c | 3 +-- vfs/dircache.c | 2 +- vfs/dirstack.c | 15 ++++++++++----- vfs/fileio.c | 27 +++++++++++++++++++-------- vfs/mkdir.c | 9 ++++++--- vfs/mknod.c | 2 +- vfs/vfs.c | 4 ++-- vfs/vfs.h | 6 +++++- 9 files changed, 46 insertions(+), 24 deletions(-) diff --git a/vfs/chmod.c b/vfs/chmod.c index 9a3d55cfd..d12907c08 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -2,7 +2,7 @@ * vfs/chmod.c - chmod wrappers (path, parent-resolved, held-dirfd). * * Includes the platform-specific lchmod/setattrlist/SYS_fchmodat2 handling and - * the leaf-safe do_fchmodat_nofollow helper. Moved verbatim out of syscall.c. + * the leaf-safe do_fchmodat_nofollow helper. * * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison * Copyright (C) 2026 Wayne Davison, Andrew Tridgell diff --git a/vfs/copy_file.c b/vfs/copy_file.c index 754739529..dca5db023 100644 --- a/vfs/copy_file.c +++ b/vfs/copy_file.c @@ -24,8 +24,7 @@ extern int preallocate_files; extern int preserve_xattrs; /* Read @p len bytes at @p ptr from descriptor @p desc, retrying if interrupted. - * Returns the number of bytes read (0 = EOF), or <0 on error. Derived from GNU - * C's cccp.c. */ + * Returns the number of bytes read (0 = EOF), or <0 on error. */ static int safe_read(int desc, char *ptr, size_t len) { int n_chars; diff --git a/vfs/dircache.c b/vfs/dircache.c index 625070b3a..125388898 100644 --- a/vfs/dircache.c +++ b/vfs/dircache.c @@ -106,7 +106,7 @@ void vfs_dircache_reset(void) if (vfs.dpc.base >= 0) close(vfs.dpc.base); vfs.dpc.base = -1; - vfs.dpc.anchor = (const char *)-2; + vfs.dpc.anchor = VFS_DPC_ANCHOR_NONE; } /* Resolve directory `dirpath` beneath `anchor` (NULL = cwd, else an absolute diff --git a/vfs/dirstack.c b/vfs/dirstack.c index 85c1003b6..eb5f16f8d 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -41,6 +41,14 @@ int path_has_dotdot_component(const char *path) return 0; } +/* True if `path` lies within directory `root` (path == root, or path begins + * with root followed by '/'). `rootlen` is strlen(root). */ +static int path_within(const char *root, size_t rootlen, const char *path) +{ + return strncmp(path, root, rootlen) == 0 + && (path[rootlen] == '\0' || path[rootlen] == '/'); +} + /* Refuse (return 1) when the ABSOLUTE resolved path `abspath` lands OUTSIDE the * serving module's root, for an operator/peer-supplied path that must stay in the * module (--partial-dir/--backup-dir/alt-basis: is_operator). An @@ -58,8 +66,7 @@ int abspath_excluded_by_module(const char *abspath, int is_operator) return 0; if (vfs.module_dirlen <= 1) /* module root is "/": nothing is outside */ return 0; - if (strncmp(abspath, vfs.module_dir, vfs.module_dirlen) == 0 - && (abspath[vfs.module_dirlen] == '\0' || abspath[vfs.module_dirlen] == '/')) + if (path_within(vfs.module_dir, vfs.module_dirlen, abspath)) return 0; /* inside the module: name-based exclude is not a boundary */ /* Not under the module root. An ABSOLUTE walk passes through the module * root's ancestors ("/", "/home", ...) on the way down -- those are not @@ -69,9 +76,7 @@ int abspath_excluded_by_module(const char *abspath, int is_operator) * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. * The --insecure-links / "insecure links = yes" opt-out short-circuits * before we get here. */ - size_t alen = strlen(abspath); - if (alen == 0 - || (strncmp(abspath, vfs.module_dir, alen) == 0 && vfs.module_dir[alen] == '/')) + if (path_within(abspath, strlen(abspath), vfs.module_dir)) return 0; /* ancestor of the module root: still descending */ return is_operator ? 1 : 0; } diff --git a/vfs/fileio.c b/vfs/fileio.c index 7424f6530..0561234d3 100644 --- a/vfs/fileio.c +++ b/vfs/fileio.c @@ -2,8 +2,6 @@ * vfs/fileio.c - fd-based file-data ops: ftruncate, lseek, fallocate, * hole-punching. * - * Moved verbatim out of syscall.c. - * * Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison * Copyright (C) 2026 Wayne Davison, Andrew Tridgell * @@ -109,6 +107,23 @@ OFF_T vfs_fallocate(int fd, OFF_T offset, OFF_T length) } #endif +/* Write all @len bytes from @ptr to @fd, retrying short writes and EINTR. + * Returns 0 on success, -1 on error. */ +static int safe_write(int fd, const char *ptr, size_t len) +{ + while (len > 0) { + int wrote = write(fd, ptr, len); + if (wrote <= 0) { + if (wrote < 0 && errno == EINTR) + continue; + return -1; + } + ptr += wrote; + len -= wrote; + } + return 0; +} + /* Punch a hole at pos for len bytes. The current file position must be at pos and will be * changed to be at pos + len. */ int vfs_punch_hole(int fd, OFF_T pos, OFF_T len) @@ -136,13 +151,9 @@ int vfs_punch_hole(int fd, OFF_T pos, OFF_T len) memset(zeros, 0, sizeof zeros); while (len > 0) { int chunk = len > (int)sizeof zeros ? (int)sizeof zeros : len; - int wrote = write(fd, zeros, chunk); - if (wrote <= 0) { - if (wrote < 0 && errno == EINTR) - continue; + if (safe_write(fd, zeros, chunk) < 0) return -1; - } - len -= wrote; + len -= chunk; } } return 0; diff --git a/vfs/mkdir.c b/vfs/mkdir.c index 8d7e0076b..f809dfb38 100644 --- a/vfs/mkdir.c +++ b/vfs/mkdir.c @@ -17,9 +17,12 @@ #include "ifuncs.h" #include "vfs/vfs_internal.h" -/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic - * quality; falls back to rand() if /dev/urandom cannot be opened or read - * (e.g. inside a chroot or container without /dev populated). */ +/* Fill buf with len random bytes for the mkstemp-style temp-name suffix. Only + * collision avoidance is needed here -- the O_EXCL|O_NOFOLLOW create is the real + * guard against a guessed/pre-planted name -- so an rand() fallback is fine when + * /dev/urandom can't be opened or read (e.g. a chroot/container without /dev). + * We read /dev/urandom directly rather than probing getrandom()/arc4random_buf() + * to match authenticate.c and avoid new configure checks. */ static void rand_bytes(unsigned char *buf, size_t len) { #ifndef O_CLOEXEC diff --git a/vfs/mknod.c b/vfs/mknod.c index a6ffbfa35..5ee356514 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -107,7 +107,7 @@ static int vfs__mknod_secure(const char *pathname, mode_t mode, dev_t dev, int f { /* HAVE_MKNODAT: older Darwin declares AT_FDCWD but not mknodat(), so * the at-variant won't build there; fall back to the plain mknod (#896). */ -#if defined(AT_FDCWD) && defined(HAVE_MKNODAT) +#if defined AT_FDCWD && defined HAVE_MKNODAT char dirpath[MAXPATHLEN]; const char *bname; const char *slash; diff --git a/vfs/vfs.c b/vfs/vfs.c index a75ee0f2d..3e91f4b92 100644 --- a/vfs/vfs.c +++ b/vfs/vfs.c @@ -17,7 +17,7 @@ * the t_*_secure test harnesses, which never run main() and so never call * vfs_init(). */ struct vfs vfs = { - .dpc = { .base = -1, .anchor = (const char *)-2 }, + .dpc = { .base = -1, .anchor = VFS_DPC_ANCHOR_NONE }, .module_dirfd = -1, }; @@ -27,7 +27,7 @@ struct vfs vfs = { void vfs_init(void) { vfs.dpc.base = -1; - vfs.dpc.anchor = (const char *)-2; + vfs.dpc.anchor = VFS_DPC_ANCHOR_NONE; vfs.dpc.depth = 0; vfs.module_dir = NULL; vfs.module_dirlen = 0; diff --git a/vfs/vfs.h b/vfs/vfs.h index fbe694a43..7421bdd29 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -70,6 +70,10 @@ #define VFS_OPERATOR_PATH (1<<1) /* operator-supplied path: ownership walk + module confinement */ #define VFS_REMOVEDIR (1<<2) /* unlink op targets a directory (AT_REMOVEDIR) */ +/* Sentinel for dpc.anchor meaning "no anchor cached" -- a non-NULL, non-pointer + * value so it can never alias a real anchor path (NULL is a valid anchor: cwd). */ +#define VFS_DPC_ANCHOR_NONE ((const char *)-2) + /* The single global VFS state instance (defined in vfs/vfs.c). * * Only curr_dir/curr_dir_len are read by mainline code; the dpc cache and the @@ -82,7 +86,7 @@ struct vfs { /* VFS-INTERNAL: held ancestor-dirfd cache. */ struct { - const char *anchor; /* anchor path, or sentinel (char *)-2 = none */ + const char *anchor; /* anchor path, or VFS_DPC_ANCHOR_NONE */ int base; /* owned anchor dir fd, or -1 */ int fd[VFS_DPC_MAXDEPTH]; /* fd after components 0..i */ char name[VFS_DPC_MAXDEPTH][256]; /* component names */ From 531f941c7d129e427c83234ae1af0b24273bb11c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 28 Jun 2026 10:23:02 +1000 Subject: [PATCH 54/69] vfs: add STRICT_CONFINEMENT build-time confinement assertion Add a CI/dev hardening mode (--enable-strict-confinement) that turns any confined-regime raw path-based metadata op into a hard abort, so a reintroduced copy-xattrs-class fallback fails the test suite instead of silently escaping through a flipped parent symlink. vfs_must_be_confined() is the predicate: the modern *at/O_NOFOLLOW primitives are present, the path is non-operator, relative and multi-component (a parent the attacker could flip), and vfs_relpath_active(). vfs_strict_confine_fail() logs and aborts. Both are absent/no-op without STRICT_CONFINEMENT, so there is no production behaviour change. --- configure.ac | 6 ++++++ vfs/secure_open.c | 37 +++++++++++++++++++++++++++++++++++++ vfs/vfs.h | 9 +++++++++ 3 files changed, 52 insertions(+) diff --git a/configure.ac b/configure.ac index 3ef31129c..afa0346b9 100644 --- a/configure.ac +++ b/configure.ac @@ -106,6 +106,12 @@ dnl by default (the knob only REMOVES a tier when explicitly disabled). AC_ARG_ENABLE(openat2, AS_HELP_STRING([--disable-openat2],[do not use Linux openat2(RESOLVE_BENEATH); force the portable resolver (for exercising the fallback tier)])) +AC_ARG_ENABLE(strict-confinement, + AS_HELP_STRING([--enable-strict-confinement],[abort if a confined receiver ever does a raw path-based metadata op (a CI/dev hardening check; no effect on a normal build)])) +if test x"$enable_strict_confinement" = x"yes"; then + AC_DEFINE(STRICT_CONFINEMENT, 1, [Define to abort on a confined-regime raw path-based metadata op (CI hardening check)]) +fi + AC_MSG_CHECKING([if md2man can create manpages]) if test x"$ac_cv_path_PYTHON3" = x; then AC_MSG_RESULT(no - python3 not found) diff --git a/vfs/secure_open.c b/vfs/secure_open.c index c8ec13891..5dec9b6c2 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -62,6 +62,43 @@ int vfs_symlink_optout_allowed(void) return insecure_links; } +/* STRICT_CONFINEMENT enforcement predicate (no effect on production behaviour). + * True when `path` is a relative, multi-component path that the receiver-side + * confinement is meant to protect -- i.e. a metadata op on it was expected to go + * through a confined fd, never a raw path-based syscall. False for: a build + * lacking the *at/O_NOFOLLOW primitives (the portability-fallback regime), an + * operator-supplied path (its own ownership walk governs it), an absolute path, + * and a top-level (no-slash) path with no parent component to flip. */ +int vfs_must_be_confined(const char *path, int is_operator) +{ +#if defined(O_NOFOLLOW) && defined(O_DIRECTORY) && defined(AT_FDCWD) + if (is_operator) + return 0; + if (!path || !*path || *path == '/') + return 0; + if (!strchr(path, '/')) + return 0; + return vfs_relpath_active(); +#else + (void)path; (void)is_operator; + return 0; +#endif +} + +#ifdef STRICT_CONFINEMENT +/* The strict build's hard stop: a confined-regime path-based metadata op was + * about to run where a confined fd was required. Loudly fail (the test suite + * then catches the reintroduced fallback) rather than silently doing an + * unconfined op a flipped parent could redirect outside the tree. */ +void vfs_strict_confine_fail(const char *path, const char *what) +{ + rprintf(FERROR, + "STRICT_CONFINEMENT violation: unconfined path-based %s on confined path \"%s\"\n", + what ? what : "metadata op", path ? path : "(null)"); + abort(); +} +#endif + /* open a file relative to a base directory. The basedir can be NULL, in which case the current working directory is used. The relpath diff --git a/vfs/vfs.h b/vfs/vfs.h index 7421bdd29..84f4ca98a 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -115,6 +115,15 @@ int vfs_symlink_optout_allowed(void); int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode); int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode); +/* STRICT_CONFINEMENT enforcement (vfs/secure_open.c): a build-time-gated CI + * harness that turns a confined-regime raw path metadata op into a hard failure. + * vfs_must_be_confined() is the predicate; vfs_strict_confine_fail() the hard + * stop, only compiled/called under STRICT_CONFINEMENT. */ +int vfs_must_be_confined(const char *path, int is_operator); +#ifdef STRICT_CONFINEMENT +void vfs_strict_confine_fail(const char *path, const char *what); +#endif + /* Operator-supplied-path resolution by ownership (vfs/owner_walk.c). */ int vfs_open_owner_walk(const char *path, int flags, mode_t mode, int is_operator); int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator); From fc23ad74c926dceae0a669ef41e7ca9d406148e3 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 28 Jun 2026 10:23:02 +1000 Subject: [PATCH 55/69] rsync,generator: assert the xattr/ACL pin invariant under STRICT_CONFINEMENT Guard the two receiver decision points that drive the xattr/ACL setters down a raw path-based branch -- set_file_attrs() and gen_entry_copy_xattrs(). In a strict build, a confined pinnable non-operator leaf that reached the setters without a confined fd (held_fd/xfd < 0 and not refused) aborts: that is exactly the copy-xattrs fallback class. The guards mirror the pin/re-pin conditions, so they cannot false-abort a legitimate transfer. Verified: under --enable-strict-confinement the suite is 210/0 with zero aborts; neutering the re-pin makes copy-xattrs-symlink-race abort at the guard. --- generator.c | 7 +++++++ rsync.c | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/generator.c b/generator.c index 981fc2d4f..ccb6fe13c 100644 --- a/generator.c +++ b/generator.c @@ -1566,6 +1566,13 @@ static int gen_entry_copy_xattrs(const char *src, const char *fname, struct file return -1; } } +#endif +#ifdef STRICT_CONFINEMENT + /* In the confined regime the dfd/re-pin paths above yield xfd >= 0 or already + * returned -1; reaching copy_xattrs with xfd < 0 while confined would let it + * path-write the dest xattrs (the copy-xattrs fallback class) -- abort. */ + if (xfd < 0 && vfs_must_be_confined(fname, 0)) + vfs_strict_confine_fail(fname, "gen_entry_copy_xattrs dest"); #endif ret = copy_xattrs(src, sfd, fname, xfd); if (sfd >= 0) diff --git a/rsync.c b/rsync.c index 5a3990c26..184b7666a 100644 --- a/rsync.c +++ b/rsync.c @@ -642,6 +642,29 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if (daemon_chmod_modes && !S_ISLNK(new_mode)) new_mode = tweak_mode(new_mode, daemon_chmod_modes); +#if (defined SUPPORT_XATTRS || defined SUPPORT_ACLS) && defined STRICT_CONFINEMENT + /* Enforce the pin/re-pin invariant: for a confined, pinnable, non-operator + * leaf with metadata work pending, the held-fd pin/re-pin above must have + * produced a confined fd (held_fd >= 0) or set xattr_refuse. Reaching here + * with neither means the xattr/ACL setters would take their raw path-based + * branch (the copy-xattrs fallback class) -- abort so the suite catches the + * regression. The clause mirrors the pin condition (excluding no-metadata- + * work, symlink/operator/opt-out, etc.); it is intentionally a touch broader + * than "the next setter definitely path-writes" (set_xattr is skipped when + * fnamecmp == NULL; a native ACL may still take a dirfd+leaf route), but a + * confined slashed path only reaches here once the invariant is already + * broken, so it cannot false-abort a legitimate transfer. */ + if (held_fd < 0 && !op_refuse && !xattr_refuse && !(flags & ATTRS_OPERATOR_PATH) + && (S_ISREG(sxp->st.st_mode) || S_ISDIR(sxp->st.st_mode) || S_ISFIFO(sxp->st.st_mode)) + && (preserve_xattrs || am_root < 0 +# ifdef SUPPORT_ACLS + || (preserve_acls && am_root >= 0) +# endif + ) + && vfs_must_be_confined(fname, 0)) + vfs_strict_confine_fail(fname, "xattr/ACL set"); +#endif + #ifdef SUPPORT_ACLS if (preserve_acls && !S_ISLNK(file->mode) && !ACL_READY(*sxp) && !op_refuse && !xattr_refuse) get_acl_fdat(held_fd, dfd, leaf, fname, sxp); From 1a8c81d9d7772fcac6f4934ba4bd4746187d1695 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 28 Jun 2026 10:23:02 +1000 Subject: [PATCH 56/69] github: run the ASan suite under --enable-strict-confinement Enable the strict confinement assertion in the ASan/UBSan CI build so the suite enforces "no confined-regime raw path metadata op" on every run. --- .github/workflows/asan-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml index 4f4b6b5ce..8e5b19e91 100644 --- a/.github/workflows/asan-build.yml +++ b/.github/workflows/asan-build.yml @@ -57,7 +57,7 @@ jobs: CC=clang \ CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \ LDFLAGS="-fsanitize=address,undefined" \ - ./configure --with-rrsync --disable-md2man + ./configure --with-rrsync --disable-md2man --enable-strict-confinement - name: make # check-progs builds rsync plus the test helper programs (tls, trimslash, # t_unsafe, ...) that runtests.py requires; plain "make" builds only rsync From 331188aaf62898b0a920fc3ebe8e752a7a530362 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 30 Jun 2026 09:09:36 +1000 Subject: [PATCH 57/69] acls: use vfs_relpath_active in the Solaris ACL branch The HAVE_SOLARIS_ACLS facl(2) paths called secure_relpath_active() -- the base-branch name -- which does not exist on the VFS branch (here the gate is vfs_relpath_active()). Linux/BSD never compile that branch, so it stayed latent; a real Solaris build fails with an implicit-declaration error. Rename both call sites (set_rsync_acl default-ACL delete and the access/default set fallback). --- acls.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acls.c b/acls.c index d51cbe9fa..52f1d3ed5 100644 --- a/acls.c +++ b/acls.c @@ -1213,7 +1213,7 @@ static int set_rsync_acl(int fd, int dirfd, const char *leaf, const char *fname, * the legacy path fallback (op_pin am_root != 0 rule). */ if (fd >= 0) rc = sys_acl_delete_def_fd(fd); - else if (secure_relpath_active() && am_root) { + else if (vfs_relpath_active() && am_root) { errno = ELOOP; rc = -1; } else @@ -1334,7 +1334,7 @@ static int set_rsync_acl(int fd, int dirfd, const char *leaf, const char *fname, sxp->st.st_mode = cur_mode; return 0; } - if (secure_relpath_active() && am_root) { + if (vfs_relpath_active() && am_root) { /* Real root always can open its own freshly-staged reg/dir/fifo leaf, * so a missing held fd on a confined receiver means the leaf was raced * to a symlink; sys_acl_set_file() follows the leaf, so refuse rather From 4c142f671bbd4e3febb6669e91a29bb84bd9d306 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 07:05:48 +1000 Subject: [PATCH 58/69] vfs: adapt the merged-in base changes to the VFS layer Linear-rebase counterpart of the conflict resolutions made when the sec-fixes base was integrated (see the merge for reference): - Port ddda7ba5's operator-path confinement of do_symlink_at and do_rmdir_at into the relocated VFS code. vfs__symlink_secure gains the VFS_OPERATOR_PATH ownership-walk branch (parent confined via vfs_owner_walk_parent, shared leaf-creation preserved so fake-super emulation still applies); vfs__unlink_secure extends its existing operator branch to the rmdir/AT_REMOVEDIR case. Callers pass the policy explicitly where the base set operator_path_resolve: the keep_backup symlink create (backup.c), the backup-tree rmdir in delete_item (delete.c, DEL_FOR_BACKUP), and handle_partial_dir's rmdir (util1.c). - Port 1f8f89c2's robust_rename EXDEV-fallback confinement into vfs/robust.c: an absolute --temp-dir/--partial-dir operand routes the copy_file dest-write and the source-unlink through the ownership walk (VFS_OPERATOR_PATH), so a raced parent symlink can't redirect either out of the module. - Drop the stale "no ownership-walk branch" notes in vfs/vfs.h and vfs/symlink.c now that symlink and rmdir carry the branch. Tree is byte-identical to the validated merge result. --- backup.c | 6 ++--- delete.c | 4 ++- util1.c | 2 +- vfs/robust.c | 13 ++++++++-- vfs/symlink.c | 71 +++++++++++++++++++++++++++++---------------------- vfs/unlink.c | 13 +++++----- vfs/vfs.h | 2 +- 7 files changed, 66 insertions(+), 45 deletions(-) diff --git a/backup.c b/backup.c index ddf5b9023..e7d555c5b 100644 --- a/backup.c +++ b/backup.c @@ -289,7 +289,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) * unsafe symlink. */ if (preserve_links && S_ISLNK(sx.st.st_mode) && safe_symlinks) { char lnkbuf[MAXPATHLEN]; - int llen = do_readlink(fname, lnkbuf, MAXPATHLEN - 1); + int llen = vfs_readlink(fname, lnkbuf, MAXPATHLEN - 1); /* A failed readlink means we can't verify the target, so fail * closed: skip the backup rather than let the hard-link fast path * preserve a possibly-unsafe symlink unchecked. */ @@ -375,9 +375,7 @@ static int make_backup_inner(const char *fname, BOOL prefer_rename) } ret = 2; } else { - /* symlink has no ownership-walk branch (see vfs/symlink.c), so - * flags=0 reproduces the old vfs_symlink_at behavior here. */ - if (vfs_symlink(sl, VFS_AT_FDCWD, buf, 0) < 0) + if (vfs_symlink(sl, VFS_AT_FDCWD, buf, VFS_OPERATOR_PATH) < 0) rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl); else if (DEBUG_GTE(BACKUP, 1)) rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname); diff --git a/delete.c b/delete.c index 97b4d3d9a..19ba0a3ea 100644 --- a/delete.c +++ b/delete.c @@ -228,7 +228,9 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) const char *leaf; int dfd = del_held_dfd(fbuf, &leaf); what = "rmdir"; - ok = (dfd >= 0 ? vfs_unlink(dfd, leaf, VFS_REMOVEDIR) : vfs_unlink(VFS_AT_FDCWD, fbuf, VFS_REMOVEDIR)) == 0; + ok = (dfd >= 0 ? vfs_unlink(dfd, leaf, VFS_REMOVEDIR) + : vfs_unlink(VFS_AT_FDCWD, fbuf, + VFS_REMOVEDIR | ((flags & DEL_FOR_BACKUP) ? VFS_OPERATOR_PATH : 0))) == 0; } else { if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) { what = "make_backup"; diff --git a/util1.c b/util1.c index 7faa03003..515a7a2f7 100644 --- a/util1.c +++ b/util1.c @@ -1095,7 +1095,7 @@ int handle_partial_dir(const char *fname, int create) return 0; } } else - vfs_unlink(VFS_AT_FDCWD, dir, VFS_REMOVEDIR); + vfs_unlink(VFS_AT_FDCWD, dir, VFS_REMOVEDIR | VFS_OPERATOR_PATH); *fn = '/'; return 1; diff --git a/vfs/robust.c b/vfs/robust.c index f0af56521..c12447171 100644 --- a/vfs/robust.c +++ b/vfs/robust.c @@ -125,9 +125,18 @@ int robust_rename(const char *from, const char *to, const char *partialptr, return -2; to = partialptr; } - if (copy_file(from, to, -1, mode, 0) != 0) + /* Cross-fs fallback: copy then unlink. An absolute --temp-dir + * source / --partial-dir dest is an operator path whose parents + * the plain-libc arm would otherwise follow -- confine them + * through the ownership walk (VFS_OPERATOR_PATH) so a raced + * parent symlink can't redirect the dest-write or the + * source-unlink out of the module. copy_file already confines + * the source READ; a relative in-module path stays on the + * secure-relative arm, so only flag an absolute (operator) + * path. */ + if (copy_file(from, to, -1, mode, *to == '/' ? VFS_OPERATOR_PATH : 0) != 0) return -2; - vfs_unlink(VFS_AT_FDCWD, from, 0); + vfs_unlink(VFS_AT_FDCWD, from, *from == '/' ? VFS_OPERATOR_PATH : 0); return 1; default: return -1; diff --git a/vfs/symlink.c b/vfs/symlink.c index d223093e0..d48faed17 100644 --- a/vfs/symlink.c +++ b/vfs/symlink.c @@ -63,13 +63,8 @@ static int vfs__symlink_plain(const char *lnk, const char *path) bare-path vfs_symlink() there, whose plain open() followed such a symlink. */ -/* NOTE: unlike vfs_mkdir/vfs_mknod, the symlink secure path has no ownership-walk - * branch -- VFS_OPERATOR_PATH is accepted but resolves the same as the default - * secure receiver walk (the link target is stored verbatim and never resolved at - * creation; only the parent dir is confined). Pre-existing asymmetry. */ static int vfs__symlink_secure(const char *lnk, const char *path, int flags) { - (void)flags; #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; const char *bname; @@ -81,32 +76,48 @@ static int vfs__symlink_secure(const char *lnk, const char *path, int flags) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; - if (!vfs_relpath_active()) - return vfs__symlink_plain(lnk, path); - - if (!path || !*path || *path == '/') - return vfs__symlink_plain(lnk, path); - - /* A path with a slash needs vfs_resolve_open to confine its parent; - * a top-level path is in CWD (AT_FDCWD), no parent to subvert. The leaf - * is protected below either way (symlinkat() won't follow it; the - * fake-super openat() uses O_NOFOLLOW). */ - slash = strrchr(path, '/'); - if (slash) { - dlen = slash - path; - if (dlen >= sizeof dirpath) { - errno = ENAMETOOLONG; - return -1; - } - memcpy(dirpath, path, dlen); - dirpath[dlen] = '\0'; - bname = slash + 1; - dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); +#if defined O_NOFOLLOW && defined O_DIRECTORY + if (flags & VFS_OPERATOR_PATH) { + /* Operator path (e.g. an absolute --backup-dir): confine the + * parent with the ownership walk, then fall through to the shared + * leaf-creation below so fake-super emulation is preserved. */ + if (vfs_symlink_optout_allowed()) + return vfs__symlink_plain(lnk, path); + dfd = vfs_owner_walk_parent(path, &bname, 1); if (dfd < 0) return -1; owns = True; - } else { - bname = path; + } else +#endif + { + (void)flags; + if (!vfs_relpath_active()) + return vfs__symlink_plain(lnk, path); + + if (!path || !*path || *path == '/') + return vfs__symlink_plain(lnk, path); + + /* A path with a slash needs vfs_resolve_open to confine its + * parent; a top-level path is in CWD (AT_FDCWD), no parent to + * subvert. The leaf is protected below either way (symlinkat() + * won't follow it; the fake-super openat() uses O_NOFOLLOW). */ + slash = strrchr(path, '/'); + if (slash) { + dlen = slash - path; + if (dlen >= sizeof dirpath) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(dirpath, path, dlen); + dirpath[dlen] = '\0'; + bname = slash + 1; + dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); + if (dfd < 0) + return -1; + owns = True; + } else { + bname = path; + } } #if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS @@ -229,8 +240,8 @@ static int vfs__symlink_atfd(const char *lnk, int dfd, const char *name) /* Unified symlink creation. dirfd == VFS_AT_FDCWD resolves `path`; a real held * dirfd makes `path` a single validated component under it. flags: - * VFS_ALLOW_SYMLINK (trusted, plain symlink), default 0 (secure receiver - * resolve). See the note on vfs__symlink_secure re VFS_OPERATOR_PATH. */ + * VFS_ALLOW_SYMLINK (trusted, plain symlink), VFS_OPERATOR_PATH (operator + * path: ownership walk), default 0 (secure receiver resolve). */ int vfs_symlink(const char *lnk, int dirfd, const char *path, int flags) { if (dirfd != VFS_AT_FDCWD) { diff --git a/vfs/unlink.c b/vfs/unlink.c index 3562a898b..e6c732945 100644 --- a/vfs/unlink.c +++ b/vfs/unlink.c @@ -19,8 +19,9 @@ /* Secure receiver-side resolve for an unlink/rmdir. unlink() resolves parent * components, so a parent-symlink swap can delete an outside file under the * daemon's authority -- defence is to resolve the parent securely and unlinkat() - * the leaf. unlink (not rmdir) honours the operator ownership walk; the rmdir - * path has no owner-walk branch (pre-existing -- matched the old vfs_rmdir_at). + * the leaf. Both unlink and rmdir honour the operator ownership walk + * (VFS_OPERATOR_PATH): a foreign-owned parent component is refused while the + * operator's own is followed, absolute and relative alike. * Falls through to a plain unlink()/rmdir() in non-daemon/sender, chrooted, * no-parent and absolute-path cases. */ static int vfs__unlink_secure(const char *path, int flags) @@ -32,13 +33,13 @@ static int vfs__unlink_secure(const char *path, int flags) int dfd, ret, e, atflag = rmdir_op ? AT_REMOVEDIR : 0; size_t dlen; - if (!rmdir_op && (flags & VFS_OPERATOR_PATH)) { + if (flags & VFS_OPERATOR_PATH) { if (vfs_symlink_optout_allowed()) - return unlink(path); + return rmdir_op ? rmdir(path) : unlink(path); dfd = vfs_owner_walk_parent(path, &bname, 1); if (dfd < 0) return -1; - ret = unlinkat(dfd, bname, 0); + ret = unlinkat(dfd, bname, atflag); e = errno; close(dfd); errno = e; @@ -75,7 +76,7 @@ static int vfs__unlink_secure(const char *path, int flags) /* Unified unlink/rmdir. dirfd == VFS_AT_FDCWD resolves `path`; a real held * dirfd makes `path` a single component removed directly under it. flags: * VFS_REMOVEDIR (rmdir/AT_REMOVEDIR instead of unlink), VFS_ALLOW_SYMLINK - * (trusted, plain), VFS_OPERATOR_PATH (operator path; unlink only), default 0 + * (trusted, plain), VFS_OPERATOR_PATH (operator path: ownership walk), default 0 * (secure receiver resolve). */ int vfs_unlink(int dirfd, const char *path, int flags) { diff --git a/vfs/vfs.h b/vfs/vfs.h index 84f4ca98a..326f1c49c 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -27,7 +27,7 @@ * it (from vfs_opendir/vfs_get_dirfd); no resolution. * * The operator-path policy is this explicit per-call flag, never ambient state. - * VFS_REMOVEDIR turns vfs_unlink into rmdir. chmod/lchown/symlink have no + * VFS_REMOVEDIR turns vfs_unlink into rmdir. chmod/lchown have no * ownership-walk branch (VFS_OPERATOR_PATH resolves as the default secure walk). * Two-path ops (vfs_rename_at, vfs_link_at) and open (distinct nofollow/ * checklinks variants) keep explicit forms + a vfs_flags arg; vfs_fstat and the From ea03fe0e9a19637e47f6aa4422c4e60cb928a3e7 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 07:15:23 +1000 Subject: [PATCH 59/69] vfs: fix the no-AT_FDCWD fallback in vfs__symlink_secure The #else arm for platforms without AT_FDCWD called the four-argument vfs_symlink() with two arguments -- a compile error on such systems. Call vfs__symlink_plain(), matching the base's do_symlink() fallback. Found by codex review; predates this rebase round. --- vfs/symlink.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vfs/symlink.c b/vfs/symlink.c index d48faed17..60b209156 100644 --- a/vfs/symlink.c +++ b/vfs/symlink.c @@ -152,7 +152,7 @@ static int vfs__symlink_secure(const char *lnk, const char *path, int flags) errno = e; return ret; #else - return vfs_symlink(lnk, path); + return vfs__symlink_plain(lnk, path); #endif } From 01e0de397b26d6b3aaf585bc41b3879214bcbaa8 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 07:38:53 +1000 Subject: [PATCH 60/69] testsuite: keep daemon test ports out of the 13000+ bloatware range daemon-exclude-namebased bound its daemon on 13010, and the setup_chroot_inner helper hashed into 12940-13139 -- both reach into 13000+, where ASUS Armoury Crate on the Cygwin CI host parks localhost listeners (13010, 13030-13032), making the port probe fail the test. The helper also used str hash(), which is per-process randomized (PYTHONHASHSEED), so its port wandered run to run. Move the fixed port to 12931 and the helper to a deterministic crc32-based slot in the otherwise-unused 12800-12859 band. --- testsuite/daemon-exclude-namebased_test.py | 4 +++- testsuite/rsyncfns.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/testsuite/daemon-exclude-namebased_test.py b/testsuite/daemon-exclude-namebased_test.py index 84275d551..269cac063 100644 --- a/testsuite/daemon-exclude-namebased_test.py +++ b/testsuite/daemon-exclude-namebased_test.py @@ -19,7 +19,9 @@ SCRATCHDIR, rmtree, rsync_argv, start_test_daemon, test_fail, write_daemon_conf, ) -DAEMON_PORT = 13010 +# Not 13000-13060: ASUS Armoury Crate on the Cygwin CI host parks localhost +# listeners there (13010 among them). +DAEMON_PORT = 12931 # (module name, exclude pattern, expect the pushed file to land) CASES = [ diff --git a/testsuite/rsyncfns.py b/testsuite/rsyncfns.py index 15f294ef4..12d21c648 100644 --- a/testsuite/rsyncfns.py +++ b/testsuite/rsyncfns.py @@ -35,6 +35,7 @@ import sys import tempfile import time +import zlib from pathlib import Path from exitcodes import Exit # re-exported: tests may `from rsyncfns import Exit` @@ -2193,7 +2194,12 @@ def setup_chroot_inner(name): ('mod', {'path': str(outer) + '/./inner', 'read only': 'no', 'use chroot': 'yes', 'munge symlinks': 'no'}), ], name=f'{name}.conf') - url = start_test_daemon(conf, 12940 + (abs(hash(name)) % 200)) + # crc32, not hash(): str hash is per-process randomized (PYTHONHASHSEED), + # so the port would wander run to run -- and the old 12940+200 span reached + # into 13000+, where desktop bloatware (e.g. ASUS Armoury Crate on the + # Cygwin CI host) parks localhost listeners. 12800-12859 is otherwise + # unused by the suite. + url = start_test_daemon(conf, 12800 + (zlib.crc32(name.encode()) % 60)) return base, inner, outside, src, url From 518c09fccfb0344cff4e65b241973f265faa3bd0 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 07:56:27 +1000 Subject: [PATCH 61/69] gitignore: ignore the remaining test-helper binaries t_hashtable_overflow, t_iwildmatch, t_clean_fname, and t_safe_arg were built by the suite but missing from .gitignore, so a stray git add -A sweeps them into a commit (as happened during this rebase round). List them alongside the other t_* harnesses. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 28b927b1c..d5a9b0998 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,10 @@ aclocal.m4 /t_rename_secure /t_secure_relpath /t_symlink_secure +/t_hashtable_overflow +/t_iwildmatch +/t_clean_fname +/t_safe_arg /simdtest /wildtest /getfsdev From 39c6c31bfca30e745c689bcf9574df80d67b922e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 14:29:05 +1000 Subject: [PATCH 62/69] vfs: adapt the merged-in base changes to the VFS layer Linear-rebase counterpart of the conflict resolutions made when the sec-fixes base was re-integrated (see the merge for reference). The base gained 5cb4b829 ("syscall: build without AT_SYMLINK_NOFOLLOW") and its CI compile-check, which touch code this branch relocated into vfs/: - Move syscall.c's RSYNC_TEST_NO_AT_FDCWD undef block into vfs/vfs.h, before the VFS_AT_FDCWD sentinel binds (so the sentinel takes its no-AT_FDCWD value rather than dangling on the undefined AT_FDCWD). - Port the AT_SYMLINK_NOFOLLOW-absent fallbacks into the relocated code: vfs/chown.c (vfs__lchown_secure + held-fd vfs_lchown gate on AT_SYMLINK_NOFOLLOW), vfs/stat.c (do_xstat_at's unused-arg casts, the vfs_lstat AT_SYMLINK_NOFOLLOW-absent arm, held-fd gate), vfs/mkdir.c (guard rand_bytes on AT_FDCWD, its only caller). - Retarget the CHECK_COMPILE_OBJS compile-check from syscall.c to a portable shell loop over the vfs sources built with -DRSYNC_TEST_NO_AT_FDCWD (Makefile.in). Tree is byte-identical to the validated merge result. --- vfs/chown.c | 4 ++-- vfs/mkdir.c | 2 ++ vfs/stat.c | 7 +++++-- vfs/vfs.h | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/vfs/chown.c b/vfs/chown.c index b61960ef7..6e5d296f0 100644 --- a/vfs/chown.c +++ b/vfs/chown.c @@ -34,7 +34,7 @@ static int vfs__lchown_plain(const char *path, uid_t owner, gid_t group) static int vfs__lchown_secure(const char *path, uid_t owner, gid_t group, int flags) { (void)flags; -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY && defined AT_SYMLINK_NOFOLLOW char dirpath[MAXPATHLEN]; const char *bname, *slash; int dfd, ret, e; @@ -78,7 +78,7 @@ int vfs_lchown(int dirfd, const char *path, uid_t owner, gid_t group, int flags) RETURN_ERROR_IF_NULL(path); if (dirfd != VFS_AT_FDCWD) { -#ifdef AT_FDCWD +#if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW /* Held-fd: reject empty, multi-component and ".." (writing the * parent of the pinned dir); "." (chown the dir itself) is a * legitimate single-component op. */ diff --git a/vfs/mkdir.c b/vfs/mkdir.c index f809dfb38..ab1c11344 100644 --- a/vfs/mkdir.c +++ b/vfs/mkdir.c @@ -23,6 +23,7 @@ * /dev/urandom can't be opened or read (e.g. a chroot/container without /dev). * We read /dev/urandom directly rather than probing getrandom()/arc4random_buf() * to match authenticate.c and avoid new configure checks. */ +#ifdef AT_FDCWD /* only vfs_mkstemp_atfd's held-dirfd create loop uses this */ static void rand_bytes(unsigned char *buf, size_t len) { #ifndef O_CLOEXEC @@ -40,6 +41,7 @@ static void rand_bytes(unsigned char *buf, size_t len) buf[i] = (unsigned char)rand(); } } +#endif void trim_trailing_slashes(char *name) { diff --git a/vfs/stat.c b/vfs/stat.c index f9ac47614..b85cf1c91 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -106,6 +106,7 @@ static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fa errno = e; return ret; #else + (void)at_flags; (void)vfs_flags; return fallback(path, st); #endif } @@ -150,7 +151,7 @@ int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags) errno = EINVAL; return -1; } -# ifdef SUPPORT_LINKS +# if defined SUPPORT_LINKS && defined AT_SYMLINK_NOFOLLOW return fstatat(dirfd, path, st, AT_SYMLINK_NOFOLLOW); # else return fstatat(dirfd, path, st, 0); @@ -161,8 +162,10 @@ int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags) } if (flags & VFS_ALLOW_SYMLINK) return vfs__lstat_plain(path, st); -#ifdef SUPPORT_LINKS +#if defined SUPPORT_LINKS && defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, vfs__lstat_plain, flags); +#elif defined SUPPORT_LINKS + return vfs__lstat_plain(path, st); #else return do_xstat_at(path, st, 0, vfs__stat_plain, flags); #endif diff --git a/vfs/vfs.h b/vfs/vfs.h index 326f1c49c..53d07b5a6 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -47,6 +47,22 @@ #ifndef RSYNC_VFS_H #define RSYNC_VFS_H +/* Exercise the pre-*at() portability tier on modern build hosts. rsync.h pulls + * this header in after the system headers that define AT_FDCWD, so stripping the + * *at primitives here strips them for the whole translation unit -- including + * the VFS_AT_FDCWD sentinel below, which then takes its no-AT_FDCWD value. The + * CHECK_COMPILE_OBJS target compiles every vfs/ source with this flag to keep + * the fallback arms building. (Was syscall.c's top before the split into vfs/; + * it must precede the VFS_AT_FDCWD definition.) */ +#ifdef RSYNC_TEST_NO_AT_FDCWD +#undef AT_FDCWD +#undef AT_SYMLINK_NOFOLLOW +#undef HAVE_LINKAT +#undef HAVE_OPENAT2 +#undef HAVE_UTIMENSAT +#undef O_RESOLVE_BENEATH +#endif + /* Max held ancestor-dirfd cache depth (was DPC_MAXDEPTH in syscall.c). */ #define VFS_DPC_MAXDEPTH 64 From 359baac8eb2db07ccc1ed5adc16cf8e66859af0c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 20 Jul 2026 14:41:25 +1000 Subject: [PATCH 63/69] vfs: fail loud in the held-fd lstat no-AT_SYMLINK_NOFOLLOW arm; make the compile-check atomic Two fixes from codex review of the no-AT_FDCWD port: - vfs/stat.c: the held-dirfd vfs_lstat branch fell back to fstatat(dirfd, path, st, 0) when AT_SYMLINK_NOFOLLOW is unavailable, which FOLLOWS the leaf and breaks lstat's no-follow contract. On a system with SUPPORT_LINKS but no AT_SYMLINK_NOFOLLOW, return ENOSYS instead (mirroring the held-fd vfs_lchown arm) so a symlink-sensitive caller fails loud rather than silently following; the !SUPPORT_LINKS arm keeps fstatat(...,0) since there is nothing to follow. The CI compile-check config also undefines AT_FDCWD so no held fd is produced there; this hardens the standalone "no AT_SYMLINK_NOFOLLOW" shape. - Makefile.in: the vfs-no-at-fdcwd.o compile loop wrote every object to $@, so a mid-loop failure left a fresh-timestamped $@ and a retry could skip the check. Compile to $@.tmp and mv to $@ only after the whole loop succeeds (rm the stale target up front); clean the .tmp too. --- Makefile.in | 6 ++++-- vfs/stat.c | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Makefile.in b/Makefile.in index 0eaa16dd6..1d50e2fdb 100644 --- a/Makefile.in +++ b/Makefile.in @@ -91,11 +91,13 @@ all: Makefile rsync$(EXEEXT) stunnel-rsyncd.conf @MAKE_RRSYNC@ @MAKE_MAN@ # make have no pattern rules); the last object compiled is left as the target. # $(VFS_OBJ:.o=.c) is POSIX suffix substitution, portable across makes. vfs-no-at-fdcwd.o: $(VFS_OBJ:.o=.c) $(HEADERS) vfs/vfs.h vfs/vfs_internal.h + @rm -f $@ $@.tmp @for f in $(VFS_OBJ:.o=.c); do \ echo " no-AT_FDCWD compile-check: $$f"; \ $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) \ - -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/$$f -o $@ || exit 1; \ + -DRSYNC_TEST_NO_AT_FDCWD -c $(srcdir)/$$f -o $@.tmp || exit 1; \ done + @mv $@.tmp $@ .PHONY: install install: all @@ -371,7 +373,7 @@ rrsync.1: support/rrsync.1.md md-convert Makefile .PHONY: clean clean: cleantests - rm -f *~ $(OBJS) $(VFS_OBJ) libvfs.a $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \ + rm -f *~ $(OBJS) $(VFS_OBJ) libvfs.a $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_COMPILE_OBJS:.o=.o.tmp) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \ git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \ *.html daemon-parm.h help-*.h default-*.h proto.h proto.h-tstamp rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda vfs/*.gcno vfs/*.gcda diff --git a/vfs/stat.c b/vfs/stat.c index b85cf1c91..f7f4e3409 100644 --- a/vfs/stat.c +++ b/vfs/stat.c @@ -153,7 +153,14 @@ int vfs_lstat(int dirfd, const char *path, STRUCT_STAT *st, int flags) } # if defined SUPPORT_LINKS && defined AT_SYMLINK_NOFOLLOW return fstatat(dirfd, path, st, AT_SYMLINK_NOFOLLOW); +# elif defined SUPPORT_LINKS + /* No AT_SYMLINK_NOFOLLOW: an fstatat via a held dirfd cannot honour + * lstat's no-follow contract, so fail loud rather than silently + * follow the leaf (mirrors the held-fd vfs_lchown ENOSYS arm); a + * symlink-sensitive caller must use the path-based form. */ + (void)dirfd; errno = ENOSYS; return -1; # else + /* No link support: nothing to follow, fstatat == lstat. */ return fstatat(dirfd, path, st, 0); # endif #else From 97d7cfc5cb7de1aa553a3dc0ced0bd6c159b1aca Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Fri, 24 Jul 2026 16:38:47 +1000 Subject: [PATCH 64/69] vfs: adapt the merged-in base changes to the VFS layer Linear-rebase counterpart of the conflict resolutions made when the sec-fixes base was re-integrated (see the merge for reference). Three of the base's new commits touch code this branch relocated or reworked: - options.c (3fe1ed51 "rsync: confine the daemon files-from open to the module root"): the base wraps the files-from open in the operator_path_resolve global, which no longer exists here. Pass the operator context explicitly instead: vfs_open_owner_walk(..., 1). - receiver.c (cfd40f55 "receiver: confine peer-selected partial basis paths"): the new relative-partial-basis branch uses the VFS resolver name, vfs_resolve_open(). - vfs/chmod.c (7b16872e "syscall: silence scan-build dead-store in do_fchmodat_nofollow fallback"): syscall.c is deleted here, so the mode masking move and the unused-arg casts land in the relocated do_fchmodat_nofollow. Tree is byte-identical to the validated merge result. --- options.c | 11 ++++++++--- receiver.c | 7 ++++++- vfs/chmod.c | 4 +++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/options.c b/options.c index f61c10ad4..04265e33d 100644 --- a/options.c +++ b/options.c @@ -27,7 +27,6 @@ extern int module_id; extern int local_server; extern int sanitize_paths; -extern int operator_path_resolve; extern int trust_sender_args; extern int trust_sender_filter; extern unsigned int module_dirlen; @@ -2642,8 +2641,14 @@ int parse_arguments(int *argc_p, const char ***argv_p) } /* Operator-supplied path that may transit attacker-writable * parents; refuse symlinks not owned by uid 0 or our euid, - * as for --exclude-from/--include-from/--filter in exclude.c. */ - filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0, 0); + * as for --exclude-from/--include-from/--filter in exclude.c. + * A daemon reads this list from a CLIENT-requested path + * (--files-from=:LIST) and it must stay inside the module: + * the is_operator walk also refuses a (trusted-owned) symlink + * that redirects the list outside the module root -- e.g. a + * root-owned backup symlink. No-op off a daemon (the module-root + * check only fires when am_daemon). */ + filesfrom_fd = vfs_open_owner_walk(files_from, O_RDONLY|O_BINARY, 0, 1); if (filesfrom_fd < 0) { snprintf(err_buf, sizeof err_buf, "failed to open files-from file %s: %s\n", diff --git a/receiver.c b/receiver.c index 8fe54b69b..150ecea0e 100644 --- a/receiver.c +++ b/receiver.c @@ -1063,7 +1063,12 @@ int recv_files(int f_in, int f_out, char *local_name) * trusted absolute fnamecmp (e.g. an absolute --partial-dir basis). */ { int bdfd; - if (!basedir && (bdfd = vfs_cached_dirfd(fnamecmp, file)) >= 0) { + if (fnamecmp_type == FNAMECMP_PARTIAL_DIR + && fnamecmp && *fnamecmp != '/') { + /* The relative partial path contains peer-derived directory + * components. It is not an operator-trusted path as a whole. */ + fd1 = vfs_resolve_open(NULL, fnamecmp, O_RDONLY, 0); + } else if (!basedir && (bdfd = vfs_cached_dirfd(fnamecmp, file)) >= 0) { const char *slash; assert(fnamecmp != NULL); /* set on every path above */ slash = strrchr(fnamecmp, '/'); diff --git a/vfs/chmod.c b/vfs/chmod.c index d12907c08..5075d523c 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -83,8 +83,8 @@ static int vfs__chmod_plain(const char *path, mode_t mode) * exists we skip with a warning rather than follow the leaf. */ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) { - mode &= CHMOD_BITS; #if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW + mode &= CHMOD_BITS; # if defined __linux__ { STRUCT_STAT st; @@ -130,6 +130,8 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) return fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); # endif #else + (void)dfd; + (void)mode; /* No symlink-safe chmod primitive here: skip rather than follow the leaf. */ rprintf(FWARNING, "vfs_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name); return 1; From fca1d10ff530c4c8d98ef223581a07c6390ae604 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 05:34:29 +1000 Subject: [PATCH 65/69] vfs: adapt the merged-in base changes to the VFS layer The base gained 99 commits since the last rebase. Nine files needed a hand-port because the change lands on code the VFS split moved or renamed, and syscall.c no longer exists here: - backup.c: make_path() now runs on a private copy of backup_dir_buf (c933f622), so vfs_make_path() takes dirbuf and drops the restore. - clientserver.c: keep both the module-root snapshot and the new daemon_config_filter_file window. - exclude.c: the peer-driven merge-file confinement is the vfs_open_owner_walk() is_operator argument, not a global. - fileio.c: the coalesced --sparse writer's new helpers use vfs_lseek()/vfs_punch_hole(). - generator.c/vfs/mknod.c: gen_entry_mknod() falls back through vfs_mknod(); the atfd path keys its mknodat() off HAVE_MKNODAT. - receiver.c: secure_recv_open() passes VFS_OPERATOR_PATH instead of toggling operator_path_resolve; open_readonly_inplace() uses the VFS stat/chmod/open wrappers. - sender.c: absolute --relative cleanup anchors at "/" via vfs_resolve_open(), the copy-links walk uses vfs_resolve_open_at_beneath(), and the source removal goes through vfs_unlink(). - vfs/chmod.c, vfs/chown.c: VFS_OPERATOR_PATH now takes the ownership walk, and the no-follow chmod grows the non-Linux fd path. - vfs/secure_open.c: the fd-anchored resolver splits into a shared internal with an allow-dotdot entry point, and secure_walk_at() routes a literal "."/".." through ds_descend() before the leaf fast paths. Tree is byte-identical to the merge oracle (tag merge-reference-10). --- backup.c | 6 +-- clientserver.c | 5 +++ exclude.c | 14 ++++++- fileio.c | 51 ++++++++++------------- generator.c | 11 ++++- receiver.c | 61 ++++++++------------------- sender.c | 42 ++++++++++++++++--- t_secure_relpath.c | 6 +-- t_symlink_secure.c | 10 ++++- vfs/chmod.c | 102 +++++++++++++++++++++++++++++++++++++++++---- vfs/chown.c | 25 ++++++++--- vfs/mknod.c | 25 ++++++++--- vfs/secure_open.c | 60 +++++++++++++++++++++----- vfs/vfs.h | 1 + 14 files changed, 298 insertions(+), 121 deletions(-) diff --git a/backup.c b/backup.c index e7d555c5b..16dc33c8c 100644 --- a/backup.c +++ b/backup.c @@ -196,10 +196,8 @@ char *get_backup_name(const char *fname) return NULL; } if (backup_dir_len > 1) - backup_dir_buf[backup_dir_len-1] = '\0'; - ret = vfs_make_path(backup_dir_buf, 0, VFS_OPERATOR_PATH); - if (backup_dir_len > 1) - backup_dir_buf[backup_dir_len-1] = '/'; + dirbuf[backup_dir_len-1] = '\0'; + ret = vfs_make_path(dirbuf, 0, VFS_OPERATOR_PATH); if (ret < 0) return NULL; initialized = 1; diff --git a/clientserver.c b/clientserver.c index ff507992c..03aa5aee9 100644 --- a/clientserver.c +++ b/clientserver.c @@ -931,6 +931,11 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char * include files just below, and the log file -- so they see the boundary. */ vfs_set_module_root(module_dir, module_dirlen, -1); + /* Everything loaded from here to the end of the exclude block is the + * operator's own configuration, so it keeps the ownership walk without the + * module-confinement parse_filter_file() applies to peer-driven merges. */ + daemon_config_filter_file = 1; + p = lp_filter(module_id); parse_filter_str(&daemon_filter_list, p, rule_template(FILTRULE_WORD_SPLIT), XFLG_ABS_IF_SLASH | XFLG_DIR2WILD3); diff --git a/exclude.c b/exclude.c index 3f3db4911..950ea7e1a 100644 --- a/exclude.c +++ b/exclude.c @@ -41,7 +41,6 @@ extern int sanitize_paths; extern int protocol_version; extern int trust_sender_args; extern int module_id; -extern int operator_path_resolve; /* Set while the daemon loads its own filter parameters; see parse_filter_file(). */ int daemon_config_filter_file = 0; @@ -1663,7 +1662,18 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ open_path = line; } else open_path = fname; - fd = vfs_open_owner_walk(open_path, O_RDONLY, 0, 0); + /* Confine the open to the module root. The ownership walk on its own + * is not enough for a peer-driven merge file: a non-chrooted daemon + * writes --backup-dir entries as root, so a raced backup symlink is + * ROOT-owned -- exactly what the ownership walk treats as trusted -- + * and naming it in a dir-merge rule would read an out-of-module file + * in as filter rules (their text comes back to the peer in "Unknown + * filter rule" errors). + * + * The daemon's own "filter"/"include from"/"exclude from" parameters + * are exempt: those are operator-configured and legitimately live + * outside the module (/etc/rsync/excludes and the like). */ + fd = vfs_open_owner_walk(open_path, O_RDONLY, 0, !daemon_config_filter_file); if (fd < 0) fp = NULL; else if (!(fp = fdopen(fd, "rb"))) diff --git a/fileio.c b/fileio.c index 7605f6e02..3e43a5ceb 100644 --- a/fileio.c +++ b/fileio.c @@ -76,17 +76,17 @@ int sparse_end(int f, OFF_T size, int updating_basis_or_equiv) * the current file position is in the file. The use_seek arg tells * us that we should seek over matching data instead of writing it. */ /* Flush any deferred run of zero bytes as a hole, advancing the file - * position past it (both do_lseek() and do_punch_hole() move the offset). */ + * position past it (both vfs_lseek() and vfs_punch_hole() move the offset). */ static int flush_sparse_hole(int f) { if (!sparse_seek) return 0; if (sparse_past_write >= preallocated_len) { - if (do_lseek(f, sparse_seek, SEEK_CUR) < 0) { + if (vfs_lseek(f, sparse_seek, SEEK_CUR) < 0) { sparse_seek = 0; return -1; } - } else if (do_punch_hole(f, sparse_past_write, sparse_seek) < 0) { + } else if (vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0) { sparse_seek = 0; return -1; } @@ -119,7 +119,7 @@ static int emit_sparse_span(int f, int use_seek, const char *buf, int len) if (flush_sparse_hole(f) < 0) return -1; if (use_seek) - return do_lseek(f, len, SEEK_CUR) < 0 ? -1 : 0; + return vfs_lseek(f, len, SEEK_CUR) < 0 ? -1 : 0; return full_sparse_write(f, buf, len); } @@ -137,13 +137,23 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int if (l1 == len) return len; - if (sparse_seek) { - if (sparse_past_write >= preallocated_len) { - if (vfs_lseek(f, sparse_seek, SEEK_CUR) < 0) - return -1; - } else if (vfs_punch_hole(f, sparse_past_write, sparse_seek) < 0) { - sparse_seek = 0; - return -1; + /* Scan the middle [l1, len-l2) for interior runs of zeros that are at + * least SPARSE_WRITE_SIZE long (the hole granularity rsync has always + * used) and defer those as holes. Everything in between -- which may + * include shorter zero runs not worth a hole -- is emitted in one go, + * rather than being chopped into SPARSE_WRITE_SIZE-byte pieces, which + * made copying a large non-sparse file cost ~one write() per KiB. + * + * The matched (use_seek) case runs through the same scan: its interior + * zero runs still have to be punched out, which is what --inplace + * --sparse relies on to keep a hole-y basis file sparse. */ + start = l1; + end = len - l2; + for (i = l1; i < end; ) { + int z; + if (buf[i] != 0) { + i++; + continue; } for (z = 1; i + z < end && buf[i+z] == 0; z++) {} if (z < SPARSE_WRITE_SIZE) { @@ -167,25 +177,6 @@ static int write_sparse(int f, int use_seek, OFF_T offset, const char *buf, int sparse_seek = l2; sparse_past_write = offset + len - l2; - if (use_seek) { - /* The in-place data already matches. */ - if (vfs_lseek(f, len - (l1+l2), SEEK_CUR) < 0) - return -1; - return len; - } - - while ((ret = write(f, buf + l1, len - (l1+l2))) <= 0) { - if (ret < 0 && errno == EINTR) - continue; - sparse_seek = 0; - return ret; - } - - if (ret != (int)(len - (l1+l2))) { - sparse_seek = 0; - return l1+ret; - } - return len; } diff --git a/generator.c b/generator.c index ccb6fe13c..3f232155c 100644 --- a/generator.c +++ b/generator.c @@ -1456,7 +1456,16 @@ static int gen_entry_mknod(const char *path, struct file_struct *file, mode_t mo /* vfs_mknod_atfd can't create a socket (no portable bindat); fall back. */ if (!S_ISSOCK(mode) && (dfd = vfs_cached_dirfd(path, file)) >= 0) { const char *slash = strrchr(path, '/'); - return vfs_mknod(dfd, slash ? slash + 1 : path, mode, rdev, 0); + int ret = vfs_mknod(dfd, slash ? slash + 1 : path, mode, rdev, 0); + /* Fall through to the unconfined path-based create only where this + * build compiled no fd-relative primitive for this kind of node -- + * SECURITY.md's rule for a platform that cannot be secure at all. + * Testing errno == ENOSYS is not that test: a live mknodat() or + * mkfifoat() can return ENOSYS too (an unimplemented FUSE mknod, + * or seccomp), which would drop confinement on a platform that + * does have the secure primitive. */ + if (ret == 0 || !no_atfd_mknod_primitive(mode)) + return ret; } return vfs_mknod(VFS_AT_FDCWD, path, mode, rdev, 0); } diff --git a/receiver.c b/receiver.c index 150ecea0e..c702c393c 100644 --- a/receiver.c +++ b/receiver.c @@ -187,13 +187,8 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags * must not downgrade an operator-path open to the ordinary path resolver. */ static int secure_recv_open(const char *path, int flags, mode_t mode, int owner_walk) { - int fd, save = operator_path_resolve; - - if (owner_walk) - operator_path_resolve = 1; - fd = secure_basis_open(NULL, path, flags, mode); - operator_path_resolve = save; - return fd; + return secure_basis_open(NULL, path, flags, mode, + owner_walk ? VFS_OPERATOR_PATH : 0); } /* Open a read-only regular file for an in-place update without leaving its @@ -215,7 +210,7 @@ static int open_readonly_inplace(const char *fname, int one_inplace) cfd = secure_recv_open(fname, O_RDONLY|O_NOFOLLOW, 0, one_inplace); if (cfd < 0) goto failed; - if (do_fstat(cfd, &cst) < 0 || !S_ISREG(cst.st_mode)) { + if (vfs_fstat(cfd, &cst) < 0 || !S_ISREG(cst.st_mode)) { errno = EACCES; /* refused: not the read-only regular file we recover */ goto failed; } @@ -251,10 +246,10 @@ static int open_readonly_inplace(const char *fname, int one_inplace) /* Local and chrooted transfers retain the existing pathname semantics. * Note the S_ISREG test here is a type check on a stable path, NOT race - * protection: do_stat() follows a leaf symlink and each call below + * protection: vfs_stat() follows a leaf symlink and each call below * re-resolves the name. The fd-based branch above is the one that * pins an inode; a chroot is what confines this one. */ - if (do_stat(fname, &cst) < 0) { + if (vfs_stat(VFS_AT_FDCWD, fname, &cst, VFS_ALLOW_SYMLINK) < 0) { errno = EACCES; return -1; } @@ -263,11 +258,11 @@ static int open_readonly_inplace(const char *fname, int one_inplace) return -1; } prior_mode = cst.st_mode & CHMOD_BITS; - if (do_chmod_at(fname, prior_mode | S_IWUSR) < 0) + if (vfs_chmod(VFS_AT_FDCWD, fname, prior_mode | S_IWUSR, 0) < 0) return -1; - fd = do_open(fname, O_WRONLY, 0600); + fd = vfs_open(fname, O_WRONLY, 0600); open_errno = errno; - if (do_chmod_at(fname, prior_mode) < 0) { + if (vfs_chmod(VFS_AT_FDCWD, fname, prior_mode, 0) < 0) { restore_errno = errno; if (fd >= 0) close(fd); @@ -1198,47 +1193,25 @@ int recv_files(int f_in, int f_out, char *local_name) * resolve it with the ownership walk (exclude-aware) so it can't be * redirected through a symlink into an excluded subtree. */ if (vfs_relpath_active()) - fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600, - one_inplace ? VFS_OPERATOR_PATH : 0); + fd2 = secure_recv_open(fnametmp, O_WRONLY|O_CREAT, 0600, + one_inplace); else fd2 = vfs_open(fnametmp, O_WRONLY|O_CREAT, 0600); #ifdef linux if (fd2 == -1 && errno == EACCES) { /* Maybe the error was due to protected_regular setting? */ - if (use_secure_symlinks) - fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); + if (use_secure_symlinks || one_inplace) + fd2 = secure_recv_open(fnametmp, O_WRONLY, 0600, + one_inplace); else fd2 = vfs_open(fnametmp, O_WRONLY, 0600); } #endif if (fd2 == -1 && errno == EACCES) { - /* A read-only existing file: make it writable, then retry - * (its mode is restored after the transfer). On a - * non-chroot daemon fchmod() a no-follow fd rather than - * chmod the path, so a symlink raced into fnametmp can't - * redirect the chmod (vfs_chmod follows the final link). */ - int errno_save = errno, chmod_ok; - if (use_secure_symlinks) { -#ifdef O_NOFOLLOW - int cfd = vfs_resolve_open(NULL, fnametmp, O_RDONLY|O_NOFOLLOW, 0); - chmod_ok = cfd != -1 && fchmod(cfd, 0600) == 0; - if (cfd != -1) - close(cfd); -#else - /* Without O_NOFOLLOW the resolver's oldest fallback would - * follow a raced symlink, so fail closed rather than - * chmod through it. */ - chmod_ok = 0; -#endif - } else - chmod_ok = vfs_chmod(VFS_AT_FDCWD, fnametmp, 0600, 0) == 0; - if (chmod_ok) { - if (use_secure_symlinks) - fd2 = vfs_resolve_open(NULL, fnametmp, O_WRONLY, 0600); - else - fd2 = vfs_open(fnametmp, O_WRONLY, 0600); - } else - errno = errno_save; + /* Temporarily add owner-write access only long enough to open + * a writable descriptor; the helper restores the old mode + * before any network data is consumed, including on failure. */ + fd2 = open_readonly_inplace(fnametmp, one_inplace); } if (fd2 == -1) { rsyserr(FERROR_XFER, errno, "open %s failed", diff --git a/sender.c b/sender.c index d2e9dd606..5b7c861cb 100644 --- a/sender.c +++ b/sender.c @@ -108,6 +108,28 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, } memcpy(dir, fname, dlen); dir[dlen] = '\0'; + /* An absolute --relative name is still rooted at / after + * change_pathname(). Resolving its parent through the cwd-backed + * dirfd cache would re-anchor cleanup at the sender's working + * directory and can remove a same-named, unrelated entry there. */ + if (*fname == '/') { + const char *rel = dir; +#ifdef __CYGWIN__ + /* clean_fname() keeps exactly two leading slashes here, + * because //server/share is a separate UNC namespace. + * Stripping them and anchoring at "/" would resolve a + * different object entirely, so decline (errno 0) and let + * the caller fall back to the path-based cleanup. */ + if (fname[1] == '/' && fname[2] != '/') { + errno = 0; + return -1; + } +#endif + while (*rel == '/') + rel++; + return vfs_resolve_open("/", rel, + O_RDONLY | O_DIRECTORY, 0); + } /* vfs_path_dirfd returns a cache-OWNED fd; the caller closes * what we return, so hand back an owned dup and leave the cache's * dirfd intact. An uncacheable (very deep) dir declines with @@ -173,12 +195,12 @@ static int secure_sender_parent_fd(struct file_struct *file, const char *fname, #endif } -/* Go through the do_*() wrapper rather than a raw unlinkat(): it carries the - * dry_run no-op and the read-only/list-only refusal that do_unlink() applies - * on the non-fd path, plus the missing-AT_FDCWD fallback. */ +/* Go through the VFS wrapper rather than a raw unlinkat(): it carries the + * dry_run no-op and the read-only/list-only refusal that the plain unlink path + * applies, plus the missing-AT_FDCWD fallback. */ static int secure_remove_source_file(int dfd, const char *bname) { - return do_unlink_atfd(dfd, bname, 0); + return vfs_unlink(dfd, bname, 0); } /* Open `relpath` (relative to `anchor`: NULL=cwd, else an absolute trusted root) @@ -265,7 +287,17 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat dir[0] = '\0'; bname = cur; } - if ((pdfd = vfs_resolve_open(anchor, dir, O_RDONLY | O_DIRECTORY, 0)) < 0) + /* anchor is checked explicitly: the resolver treats a NULL anchor as + * "relative to cwd", so it is a legal argument for the else branch -- + * only this branch would hand it to strcmp(). */ + if (am_daemon && module_dirfd >= 0 && module_dir && anchor + && strcmp(anchor, module_dir) == 0) + pdfd = vfs_resolve_open_at_beneath(module_dirfd, dir, + O_RDONLY | O_DIRECTORY, 0); + else + pdfd = vfs_resolve_open(anchor, dir, + O_RDONLY | O_DIRECTORY, 0); + if (pdfd < 0) return -1; n = vfs_readlink_atfd(pdfd, bname, tgt, sizeof tgt - 1); e = errno; diff --git a/t_secure_relpath.c b/t_secure_relpath.c index 0bef02114..c8083e6a9 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -111,7 +111,7 @@ static void check_beneath_dotdot(void) return; } - fd = secure_relative_open_at_beneath(anchor, "alias/../subdir", + fd = vfs_resolve_open_at_beneath(anchor, "alias/../subdir", O_RDONLY | O_DIRECTORY, 0); if (fd < 0 || fstat(fd, &fst) < 0 || fst.st_dev != ast.st_dev || fst.st_ino == ast.st_ino) { @@ -138,7 +138,7 @@ static void check_beneath_dotdot(void) for (ci = 0; ci < sizeof dotdot_cases / sizeof *dotdot_cases; ci++) { int dfd; errno = 0; - dfd = secure_relative_open_at_beneath(anchor, "..", + dfd = vfs_resolve_open_at_beneath(anchor, "..", dotdot_cases[ci].flags, 0); if (dfd >= 0) { STRUCT_STAT dst; @@ -159,7 +159,7 @@ static void check_beneath_dotdot(void) } errno = 0; - fd = secure_relative_open_at_beneath(anchor, "../outside", + fd = vfs_resolve_open_at_beneath(anchor, "../outside", O_RDONLY | O_DIRECTORY, 0); if (fd >= 0 || errno != ELOOP) { fprintf(stderr, "FAIL [beneath escape]: rc=%d errno=%d, expected -1/ELOOP\n", diff --git a/t_symlink_secure.c b/t_symlink_secure.c index ea348cfe6..6a501baf3 100644 --- a/t_symlink_secure.c +++ b/t_symlink_secure.c @@ -89,8 +89,8 @@ int main(int argc, char **argv) const char *moddir; # if !defined(HAVE_MKNODAT) && !defined(TEST_SYMLINK_PLACEHOLDER) - /* Nothing left to assert: the do_mknod_at() checks need mknodat(), and - * the do_symlink_at() ones are not compiled here. Skip rather than + /* Nothing left to assert: the vfs_mknod() checks need mknodat(), and + * the vfs_symlink() ones are not compiled here. Skip rather than * pass vacuously. */ (void)argc; (void)argv; fprintf(stderr, "SKIP: no mknodat() and no symlink placeholders -- " @@ -141,11 +141,17 @@ int main(int argc, char **argv) check_preserved("vfs_symlink slashed", "../outside/secret_sym2", "VICTIM_SYM2"); #endif +# ifdef HAVE_MKNODAT + /* Without mknodat() the secure vfs_mknod() IS the plain mknod(): the + * confinement is compiled out by design (SECURITY.md), so these would + * assert a property the build deliberately does not have. The + * vfs_symlink() checks above do not depend on it and still run. */ vfs_mknod(VFS_AT_FDCWD, "nodpath", S_IFCHR | 0600, 0, 0); check_preserved("vfs_mknod bare", "../outside/secret_nod", "VICTIM_NOD"); vfs_mknod(VFS_AT_FDCWD, "sub/nodpath2", S_IFCHR | 0600, 0, 0); check_preserved("vfs_mknod slashed", "../outside/secret_nod2", "VICTIM_NOD2"); +# endif if (errs) fprintf(stderr, "%d failure(s)\n", errs); diff --git a/vfs/chmod.c b/vfs/chmod.c index 5075d523c..d7c717cc4 100644 --- a/vfs/chmod.c +++ b/vfs/chmod.c @@ -74,20 +74,30 @@ static int vfs__chmod_plain(const char *path, mode_t mode) * attacker swapping the leaf to a symlink that fchmodat(...,0) would follow out * of the tree). * - * Never follows the leaf: a regular file, dir or FIFO is pinned via + * Never follows the leaf: a regular file or dir is pinned via * openat(O_NOFOLLOW) and chmod'd with fchmod() (leaf-safe, every kernel, and * fakeroot-wrappable unlike the raw fchmodat2() syscall); a symlink leaf is - * refused (ELOOP). Other types or an open failure fall to - * fchmodat(AT_SYMLINK_NOFOLLOW) (a real no-follow chmod on glibc>=2.32 / - * Linux>=6.6), then the raw fchmodat2() syscall. If no no-follow primitive - * exists we skip with a warning rather than follow the leaf. */ + * refused (ELOOP, or EMLINK/EFTYPE on the BSDs). Other types or an open + * failure fall to fchmodat(AT_SYMLINK_NOFOLLOW) (a real no-follow chmod on + * glibc>=2.32 / Linux>=6.6), then the raw fchmodat2() syscall. If no + * no-follow primitive exists we skip with a warning rather than follow the + * leaf. + * + * A FIFO takes the fd path on Linux and the pathname path elsewhere -- see the + * S_ISFIFO arm below for why, and for what that costs. Note the type used to + * choose between them comes from the lstat above, so a leaf swapped between + * that and the open is classified by what it WAS: an observed regular file or + * dir that becomes a FIFO is still opened. O_NOFOLLOW rejects symlinks, not + * type changes. Constraining the open to the observed type would close that; + * it is not done here. */ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) { #if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW mode &= CHMOD_BITS; -# if defined __linux__ +# ifdef O_NOFOLLOW { STRUCT_STAT st; + int oflags = O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY; if (vfs_lstat(dfd, name, &st, 0) < 0) return -1; if (S_ISLNK(st.st_mode)) { @@ -95,18 +105,72 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) return -1; } if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode) || S_ISFIFO(st.st_mode)) { - int fd = openat(dfd, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY | O_CLOEXEC); + int fd; +# ifndef __linux__ + /* Never open a FIFO here. Opening one -- even O_NONBLOCK -- + * makes this process a reader for as long as the descriptor + * lives, which wakes a writer blocked in open(O_WRONLY) and + * can cost it a SIGPIPE or the bytes it writes before we + * close. The pathname call reaches the same end state + * without that: it succeeds outright when the mode is + * grantable, and when macOS refuses an ungrantable setgid + * with EPERM (having applied nothing), asking again without + * that bit gives exactly what fchmod() would have -- it drops + * the bit it cannot grant and applies the ordinary ones. + * Measured on macOS: fchmodat(2750) EPERM leaving 0600, + * fchmodat(0750) ok giving 0750, for a FIFO and a directory + * alike. + * + * This is a pathname call, so unlike the descriptor path it + * does not pin the inode; a leaf swapped for another object + * of the same name is chmod'd instead. AT_SYMLINK_NOFOLLOW + * still keeps it off a symlink's target. That trade buys + * away the reader hazard, and only for FIFOs. + * + * Only S_ISGID is retried. An ungrantable S_ISUID would + * still fail where fchmod() would have cleared it, but + * setuid is meaningless on a FIFO and the behaviour is + * undemonstrated, so it is not coded for. + * + * Linux keeps the fd-first order it has always had. */ + if (S_ISFIFO(st.st_mode)) { + if (fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW) == 0) + return 0; + if (errno == EPERM && (mode & S_ISGID) + && fchmodat(dfd, name, mode & ~S_ISGID, + AT_SYMLINK_NOFOLLOW) == 0) + return 0; + return -1; + } +# endif +# ifdef O_CLOEXEC + oflags |= O_CLOEXEC; +# endif + fd = openat(dfd, name, oflags); if (fd >= 0) { int r = fchmod(fd, mode), e = errno; close(fd); errno = e; return r; } - if (errno == ELOOP) + /* A leaf swapped for a symlink between the lstat above and + * this open: refuse rather than fall through. The errno is + * not the same everywhere -- Linux/Solaris ELOOP, FreeBSD + * EMLINK, NetBSD EFTYPE. */ + if (errno == ELOOP +# ifdef EMLINK + || errno == EMLINK +# endif +# ifdef EFTYPE + || errno == EFTYPE +# endif + ) return -1; /* raced to a symlink: refuse */ /* otherwise (e.g. EACCES on an unreadable file) fall through */ } } +# endif +# if defined __linux__ { int r = fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW); if (r == 0) @@ -166,7 +230,6 @@ static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode) */ static int vfs__chmod_secure(const char *fname, mode_t mode, int flags) { - (void)flags; /* chmod has no ownership-walk branch (like vfs_lchown) */ #ifdef AT_FDCWD char dirpath[MAXPATHLEN]; const char *bname; @@ -177,6 +240,26 @@ static int vfs__chmod_secure(const char *fname, mode_t mode, int flags) if (dry_run) return 0; RETURN_ERROR_IF_RO_OR_LO; +#if defined O_NOFOLLOW && defined O_DIRECTORY + /* Operator-supplied path: resolve the parent via the ownership walk, as + * the other VFS wrappers do. Without this the caller's VFS_OPERATOR_PATH + * has no effect here, and an absolute name would fall straight through to + * the unconfined full-path chmod. S_ISLNK(mode) still needs the plain + * lchmod()/setattrlist() handling. */ + if ((flags & VFS_OPERATOR_PATH) && fname && *fname && !S_ISLNK(mode)) { + if (vfs_symlink_optout_allowed()) + return vfs__chmod_plain(fname, mode); + dfd = vfs_owner_walk_parent(fname, &bname, 1); + if (dfd < 0) + return -1; + ret = do_fchmodat_nofollow(dfd, bname, mode); + e = errno; + close(dfd); + errno = e; + return ret; + } +#endif + /* Only the daemon-without-chroot case is exposed to the symlink- * race attack: a chroot already confines the receiver, and a * non-daemon rsync runs with the user's own authority so a @@ -212,6 +295,7 @@ static int vfs__chmod_secure(const char *fname, mode_t mode, int flags) errno = e; return ret; #else + (void)flags; return vfs__chmod_plain(fname, mode); #endif } diff --git a/vfs/chown.c b/vfs/chown.c index 6e5d296f0..34a3c4ae4 100644 --- a/vfs/chown.c +++ b/vfs/chown.c @@ -27,19 +27,33 @@ static int vfs__lchown_plain(const char *path, uid_t owner, gid_t group) /* Secure receiver-side resolve: open the parent under vfs_resolve_open() and * fchownat(..., AT_SYMLINK_NOFOLLOW) so a parent-component symlink swap can't - * redirect the chown outside the module. Like vfs_chmod, lchown has no - * ownership-walk branch (VFS_OPERATOR_PATH resolves the same as the default - * secure walk). Falls through to the plain lchown in non-daemon/sender, - * chrooted, no-parent and absolute-path cases. */ + * redirect the chown outside the module. VFS_OPERATOR_PATH takes the ownership + * walk instead, as the other VFS wrappers do. Falls through to the plain + * lchown in non-daemon/sender, chrooted, no-parent and absolute-path cases. */ static int vfs__lchown_secure(const char *path, uid_t owner, gid_t group, int flags) { - (void)flags; #if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY && defined AT_SYMLINK_NOFOLLOW char dirpath[MAXPATHLEN]; const char *bname, *slash; int dfd, ret, e; size_t dlen; + /* Operator-supplied path: without this branch the caller's + * VFS_OPERATOR_PATH has no effect here and an absolute name would fall + * straight through to the unconfined full-path lchown. */ + if ((flags & VFS_OPERATOR_PATH) && path && *path) { + if (vfs_symlink_optout_allowed()) + return vfs__lchown_plain(path, owner, group); + dfd = vfs_owner_walk_parent(path, &bname, 1); + if (dfd < 0) + return -1; + ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); + e = errno; + close(dfd); + errno = e; + return ret; + } + if (!vfs_relpath_active() || !*path || *path == '/') return vfs__lchown_plain(path, owner, group); slash = strrchr(path, '/'); @@ -63,6 +77,7 @@ static int vfs__lchown_secure(const char *path, uid_t owner, gid_t group, int fl errno = e; return ret; #else + (void)flags; return vfs__lchown_plain(path, owner, group); #endif } diff --git a/vfs/mknod.c b/vfs/mknod.c index 5ee356514..8d973d738 100644 --- a/vfs/mknod.c +++ b/vfs/mknod.c @@ -125,8 +125,19 @@ static int vfs__mknod_secure(const char *pathname, mode_t mode, dev_t dev, int f dfd = vfs_owner_walk_parent(pathname, &bname, 1); if (dfd < 0) return -1; - ret = mknodat(dfd, bname, mode, dev); - if (ret < 0) { + if (am_root < 0) { + /* Fake-super represents a special file with an inert regular + * placeholder. Keep that representation when the destination + * is an operator path, but create it relative to the verified + * parent so the confinement guarantee is unchanged. */ + int fd = openat(dfd, bname, + O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, + S_IWUSR | S_IRUSR); + ret = fd < 0 ? -1 : close(fd); + } else { + ret = mknodat(dfd, bname, mode, dev); + } + if (ret < 0 && am_root >= 0) { /* mknodat() can't make a FIFO/socket on the BSDs/macOS/ * Solaris (EINVAL); retry race-safely on the held dirfd, * mirroring the secure-relpath path below. Without this a @@ -244,8 +255,10 @@ static int vfs__mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) } /* Try mknodat first; on failure retry race-safely with the type- - * specific primitive (see vfs_mknod()). */ -#ifdef HAVE_MKNOD + * specific primitive (see vfs_mknod()). HAVE_MKNODAT, not HAVE_MKNOD: + * older Darwin has mknod() but not mknodat(), so keying off the former + * compiles a call that then fails to link (#161). */ +#ifdef HAVE_MKNODAT if (mknodat(dfd, name, mode, dev) == 0) return 0; #endif @@ -259,9 +272,11 @@ static int vfs__mknod_atfd(int dfd, const char *name, mode_t mode, dev_t dev) errno = EOPNOTSUPP; return -1; } -#ifdef HAVE_MKNOD +#ifdef HAVE_MKNODAT return -1; /* mknodat()'s errno (regular/device node) */ #else + /* Must match the guard above: reporting "mknodat()'s errno" where the + * call was never compiled would return a stale errno. */ (void)dev; errno = ENOSYS; return -1; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index 5dec9b6c2..c9383c873 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -169,6 +169,25 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, int is_last = (size_t)(part - path_copy) == last_off; saw_component = 1; + /* A literal "." or ".." is a movement, not a name to open. It must go + * through ds_descend(), which refuses to pop above the anchor, BEFORE + * the leaf fast paths below -- those openat() the component directly, + * so a final ".." would otherwise hand back the anchor's own parent + * (with O_NOFOLLOW) or open it transiently (without O_DIRECTORY). */ + if (part[0] == '.' + && (part[1] == '\0' || (part[1] == '.' && part[2] == '\0'))) { + if (ds_descend(&ds, part, hops) < 0) + goto cleanup; + if (is_last) { + if (flags & O_DIRECTORY) + retfd = ds_take(&ds); + else + errno = EISDIR; + goto cleanup; + } + continue; + } + /* File leaf (final component, caller did not ask for O_DIRECTORY): * never follow a symlink leaf. */ if (is_last && !(flags & O_DIRECTORY)) { @@ -368,18 +387,16 @@ int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t #endif // O_NOFOLLOW, O_DIRECTORY } -/* Like vfs_resolve_open() but anchored at an already-open directory fd - * (borrowed -- the caller keeps ownership) rather than a basedir path. Lets a - * caller pin the trust root once -- e.g. a daemon's module root opened while - * still privileged, or reached by climbing ".." up from the cwd -- and resolve a - * relative path beneath it without re-walking (and re-permission-checking) the - * absolute path as a dropped-privilege uid. `relpath` must be relative and must - * not contain a literal ".." component (a ".." inside a followed in-tree symlink - * target is still handled by the walk). */ -int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode) +/* Common fd-anchored resolver. A caller may explicitly allow literal ".." + * components when the fd itself is the confinement boundary: secure_walk_at() + * resolves each one by popping its held-dirfd stack and refuses a pop above the + * anchor. Other callers retain the front-door validation used by + * vfs_resolve_open(). */ +static int vfs__resolve_open_at_internal(int anchor_fd, const char *relpath, + int flags, mode_t mode, int allow_dotdot) { #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) - (void)anchor_fd; (void)relpath; (void)flags; (void)mode; + (void)anchor_fd; (void)relpath; (void)flags; (void)mode; (void)allow_dotdot; errno = ENOSYS; return -1; #else @@ -388,7 +405,7 @@ int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mo errno = EINVAL; return -1; } - if (path_has_dotdot_component(relpath)) { + if (!allow_dotdot && path_has_dotdot_component(relpath)) { errno = EINVAL; return -1; } @@ -403,6 +420,27 @@ int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mo #endif } +/* Like vfs_resolve_open() but anchored at an already-open directory fd + * (borrowed -- the caller keeps ownership) rather than a basedir path. Lets a + * caller pin the trust root once -- e.g. a daemon's module root opened while + * still privileged -- and resolve a relative path beneath it without re-walking + * the absolute path as a dropped-privilege uid. The ordinary entry point keeps + * rejecting literal ".." components as suspicious caller input. */ +int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode) +{ + return vfs__resolve_open_at_internal(anchor_fd, relpath, flags, mode, 0); +} + +/* Resolve a path that may contain literal ".." beneath a trusted anchor fd. + * Used for a followed symlink target, where parent-relative components are + * normal pathname semantics. The held-fd stack still refuses every escape + * above anchor_fd. */ +int vfs_resolve_open_at_beneath(int anchor_fd, const char *relpath, + int flags, mode_t mode) +{ + return vfs__resolve_open_at_internal(anchor_fd, relpath, flags, mode, 1); +} + /* Resolve ONE operand of a two-path op (rename/link) to a parent dirfd + leaf, * per that operand's OWN policy -- so a two-path op can confine each side * independently (an operator basis/backup path on one side must not relax the diff --git a/vfs/vfs.h b/vfs/vfs.h index 53d07b5a6..b4bdd516e 100644 --- a/vfs/vfs.h +++ b/vfs/vfs.h @@ -130,6 +130,7 @@ int vfs_relpath_active(void); int vfs_symlink_optout_allowed(void); int vfs_resolve_open(const char *basedir, const char *relpath, int flags, mode_t mode); int vfs_resolve_open_at(int anchor_fd, const char *relpath, int flags, mode_t mode); +int vfs_resolve_open_at_beneath(int anchor_fd, const char *relpath, int flags, mode_t mode); /* STRICT_CONFINEMENT enforcement (vfs/secure_open.c): a build-time-gated CI * harness that turns a confined-regime raw path metadata op into a hard failure. From 730e619767c0e5b78a1d6e89b15d284842f8542b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 06:27:06 +1000 Subject: [PATCH 66/69] vfs: give set_file_attrs' path-based chmod/chown the operator policy Base commit 0bfcd3b0 taught do_chmod_at()/do_lchown_at() to resolve an operator-supplied path through the ownership walk. The previous commit ported that into vfs_chmod()/vfs_lchown(), but on this branch the policy is a per-call argument rather than the ambient operator_path_resolve global, and set_file_attrs() still passed 0 -- so the ported branch was unreachable and the fix was inert. op_pin already covers a reg/dir/fifo leaf with a pinned fd, which is stronger than the walk. What it does not cover reaches the path-based fallbacks: a symlink or device leaf never enters op_pin, and a non-root operator can fail the pin open with an ordinary EACCES and fall through with op_refuse clear. Both then resolved the full operator path with a bare lchown()/chmod(). Derive the flag from ATTRS_OPERATOR_PATH and pass it to the two VFS_AT_FDCWD fallbacks only; the held-dirfd arms stay at 0, since a pinned parent already confines them. vfs_chmod()'s operator branch skips S_ISLNK on its own, so a symlink-as-object keeps the lchmod()/setattrlist() path, matching the base. Also drop ten .gitignore entries the rebase duplicated: this branch's own test-helper block and the base's new one list the same binaries. Suite 259/0/83; the operator-path, backup and temp-dir families pass as root over --use-tcp. Instrumenting the branch shows it is now reached 92 times across those tests (the chmod side); the lchown side has no test exercising it, which is how the gap survived upstream. --- .gitignore | 10 ---------- rsync.c | 11 +++++++++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index d5a9b0998..d7116bfe0 100644 --- a/.gitignore +++ b/.gitignore @@ -55,17 +55,7 @@ aclocal.m4 /simdtest /wildtest /getfsdev -/simdtest -/t_acl -/t_chmod_secure -/t_clean_fname -/t_hashtable_overflow -/t_iwildmatch -/t_rename_secure -/t_safe_arg /t_safe_arg_main -/t_secure_relpath -/t_symlink_secure /rounding.h /doc/rsync.pdf /doc/rsync.ps diff --git a/rsync.c b/rsync.c index 184b7666a..1f0322598 100644 --- a/rsync.c +++ b/rsync.c @@ -513,6 +513,13 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, int op_leaf_fd = -1; /* O_NOFOLLOW fd pinning a cross-tree operator leaf */ int op_pin = 0; /* drive chmod/chown off op_leaf_fd for a cross-tree leaf */ int op_refuse = 0; /* pin open hit the symlink-race signal: refuse, don't redirect */ + /* The ownership walk for the path-based chmod/chown fallbacks below. op_pin + * covers a reg/dir/fifo leaf with a pinned fd, but a symlink or device leaf + * never enters it, and a non-root operator can fail the pin open with a plain + * EACCES and fall through -- both must still resolve the operator path via the + * walk rather than a bare lchown()/chmod(). (vfs_chmod's operator branch skips + * S_ISLNK itself, so a symlink-as-object keeps the lchmod/setattrlist path.) */ + int op_vfs = (flags & ATTRS_OPERATOR_PATH) ? VFS_OPERATOR_PATH : 0; #if defined SUPPORT_XATTRS || defined SUPPORT_ACLS int held_fd = -1; /* held O_NOFOLLOW fd for fd-based xattr/ACL ops, or -1 */ int xattr_refuse = 0; /* no confined fd for a slashed path: skip path-based xattr/ACL */ @@ -697,7 +704,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, if ((op_leaf_fd >= 0 ? vfs_fchown(op_leaf_fd, uid, gid) : op_refuse ? (errno = ELOOP, -1) : dfd >= 0 ? vfs_lchown(dfd, leaf, uid, gid, 0) - : vfs_lchown(VFS_AT_FDCWD, fname, uid, gid, 0)) != 0) { + : vfs_lchown(VFS_AT_FDCWD, fname, uid, gid, op_vfs)) != 0) { /* We shouldn't have attempted to change uid * or gid unless have the privilege. */ rsyserr(FERROR_XFER, errno, "%s %s failed", @@ -827,7 +834,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, : op_leaf_fd >= 0 ? vfs_fchmod(op_leaf_fd, new_mode) : op_refuse ? (errno = ELOOP, -1) : dfd >= 0 && !S_ISLNK(new_mode) ? vfs_chmod(dfd, leaf, new_mode, 0) - : vfs_chmod(VFS_AT_FDCWD, fname, new_mode, 0); + : vfs_chmod(VFS_AT_FDCWD, fname, new_mode, op_vfs); if (ret < 0) { rsyserr(FERROR_XFER, errno, "failed to set permissions on %s", From 893e88ade185cb8e36b7dbebb9aed7d7cef9e7b4 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 06:48:38 +1000 Subject: [PATCH 67/69] testsuite: cover the backup-dir ownership set under a parent swap The previous commit gave set_file_attrs()' path-based chmod/chown the operator ownership walk, but nothing exercised the chown side: the instrumented branch was reached 92 times by the existing suite, all of them chmod. That is the same gap that let the upstream fix ship inert. The uncovered path is a SYMLINK backup. op_pin cannot pin a symlink -- there is no O_NOFOLLOW open of one -- so make_backup()'s set_file_attrs() falls through to the full-path lchown. Without the walk, a backup parent flipped to an attacker-owned symlink redirects that lchown onto a victim outside the backup tree and retags it as the attacker's. Two things make this awkward to test, and both are why it was missed: - On one filesystem make_backup() hard-links or renames the item into the backup dir and never calls set_file_attrs() at all. The fixture therefore puts the backup dir on tmpfs so link and rename fail EXDEV and the recreate path runs. backup-crossdev-copy probes the same way. - A statically planted symlink proves nothing: rsync's own backup-dir validation deletes a non-directory component before using it. The plant has to be a live flip, as in operator-path-backup-symlink. The positive control checks both that the symlink reached the backup tree and that the backup copy carries the attacker's uid -- i.e. that an lchown actually ran. Without that second assertion the race would pass vacuously on any build where the chown never happens. RED on the parent of the previous commit (victim outside/f94 retagged away from root); GREEN here. Runs on Linux CI as root; registered as an expected skip on Cygwin (root-only) and macOS (root-only, and no cross-device tmpfs -- backup-crossdev-copy is listed there for the same reason). Full expected-skip oracle passes exactly. --- testsuite/operator-path-backup-chown_test.py | 184 +++++++++++++++++++ testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 3 files changed, 186 insertions(+) create mode 100644 testsuite/operator-path-backup-chown_test.py diff --git a/testsuite/operator-path-backup-chown_test.py b/testsuite/operator-path-backup-chown_test.py new file mode 100644 index 000000000..1ff1d937a --- /dev/null +++ b/testsuite/operator-path-backup-chown_test.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# --backup-dir parent-component symlink-race confinement for the OWNERSHIP set, +# not the create. operator-path-backup-symlink covers the create side (a backup +# symlink must not be written outside the backup tree); this covers what +# set_file_attrs() does to the item afterwards. +# +# make_backup() recreates the item at the backup name and then calls +# set_file_attrs(buf, ..., ATTRS_OPERATOR_PATH). A regular/dir/fifo leaf is +# pinned by op_pin and its metadata driven off that fd, but a SYMLINK leaf never +# enters op_pin (there is no O_NOFOLLOW open of a symlink), so the chown falls +# through to the path-based wrapper on the full operator path. Unless that +# wrapper resolves through the ownership walk, a parent component flipped to an +# attacker-owned symlink redirects the lchown out of the backup tree and retags +# a victim inode as the attacker's -- an ownership-transfer primitive, and the +# trust laundering that then defeats the walk on any later pass. +# +# Reaching the recreate path at all needs the backup dir on ANOTHER filesystem: +# on one filesystem make_backup() hard-links or renames the item across and +# never calls set_file_attrs(). So the whole fixture lives on tmpfs. +# +# A statically planted symlink is not enough either -- rsync's own backup-dir +# validation deletes a non-directory component before using it -- so the plant +# has to be a live flip, as in the sibling test. + +import os +import subprocess +import time + +from rsyncfns import ( + SCRATCHDIR, race_budget, find_attacker_uid, rmtree, makepath, + start_c_flipper, stop_flipper, test_fail, test_skipped, +) + +if os.geteuid() != 0: + test_skipped("requires root to own a symlink by a foreign uid and to chown backups") + +ATT_UID = find_attacker_uid() +if ATT_UID is None: + test_skipped("no untrusted-uid user available for cross-uid plant") + +# The backup dir must be on a different st_dev from the destination, or +# make_backup() renames into it and the set_file_attrs() path never runs. +dest_dev = os.stat(SCRATCHDIR).st_dev +TMPFS = None +for cand in ('/dev/shm', '/run/shm', os.environ.get('TMPDIR', '/tmp')): + try: + if os.stat(cand).st_dev != dest_dev and os.access(cand, os.W_OK): + TMPFS = cand + break + except OSError: + continue +if TMPFS is None: + test_skipped("no writable cross-device dir (tmpfs) for the --backup-dir EXDEV path") + +# Many files widen the per-file backup window so the flipper has more chances to +# land the swap between the recreate and the chown. +NFILES = 95 + +base = SCRATCHDIR / 'bdir-chown-race' +src = base / 'src' +dest = base / 'dest' + +bakroot = os.path.join(TMPFS, 'rsync-bakchown-race') +backup = os.path.join(bakroot, 'backup') +outside = os.path.join(bakroot, 'outside') +sub = os.path.join(backup, 'sub') +sublink = os.path.join(backup, '.sublink') + + +def build(): + """Reset the workspace. Call only while the flipper is stopped.""" + rmtree(base) + subprocess.run(['rm', '-rf', bakroot], check=False) + makepath(src / 'sub', dest / 'sub') + os.makedirs(outside, exist_ok=True) + os.makedirs(backup, exist_ok=True) + + # Distinct source and destination symlink values so each transfer replaces + # the destination symlink and thus backs the old one up. The destination + # symlinks are attacker-owned, so restoring their ownership onto the backup + # copy REQUIRES an lchown -- without that there is no chown to redirect and + # the test would pass vacuously. + for i in range(NFILES): + (src / 'sub' / f'f{i}').symlink_to('test') + d = dest / 'sub' / f'f{i}' + d.symlink_to('test2') + os.lchown(d, ATT_UID, ATT_UID) + + # Victims: root-owned regular files carrying the names the backup would use + # if a flipped `sub` redirected the operator path into outside/. + for i in range(NFILES): + v = os.path.join(outside, f'f{i}') + with open(v, 'w') as fh: + fh.write('victim\n') + os.chown(v, 0, 0) + + # The attacker-owned parent-swap target. + os.symlink(outside, sublink) + os.lchown(sublink, ATT_UID, ATT_UID) + os.makedirs(sub, exist_ok=True) + + +def push(): + """Blocking local rsync push that backs up the old destination symlinks.""" + return subprocess.run( + ['./rsync', '-a', '-b', f'--backup-dir={backup}', f'{src}/', f'{dest}/'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + + +def retagged(): + """Name of a victim in outside/ that stopped being root-owned, or ''. + + A swap killed mid-rename can leave outside/ momentarily odd; anything we + cannot stat is simply not evidence of a win.""" + try: + with os.scandir(outside) as it: + for e in it: + try: + st = e.stat(follow_symlinks=False) + except OSError: + continue + if st.st_uid != 0 or st.st_gid != 0: + return e.name + except (FileNotFoundError, NotADirectoryError): + return '' + return '' + + +# ---- POSITIVE CONTROL ------------------------------------------------------ +# A clean run must (a) back the old destination symlink into the backup tree via +# the cross-device recreate path and (b) carry the attacker ownership onto that +# backup copy -- which is the lchown this test is about. Without both, the race +# below would be asserting on a code path that never executes. +build() +proc = push() +if proc.returncode != 0: + test_fail(f"positive control: clean --backup-dir run failed (rc={proc.returncode}):\n{proc.stdout or ''}") + +bak0 = os.path.join(sub, 'f0') +if not os.path.islink(bak0) or os.readlink(bak0) != 'test2': + test_fail(f"positive control: the old destination symlink was not backed up into {sub}; " + "the cross-device recreate path was not exercised") +st = os.lstat(bak0) +if st.st_uid != ATT_UID: + test_fail(f"positive control: backup copy {bak0} is uid {st.st_uid}, expected the " + f"attacker uid {ATT_UID}; set_file_attrs() did not lchown the backup, so " + "this test would pass vacuously") +if retagged(): + test_fail("positive control: a victim in outside/ changed ownership during a no-flipper run") + + +# ---- THE LIVE RACE --------------------------------------------------------- +# Flip backup/sub between the real backup directory and the attacker-owned +# symlink to outside/ under a live transfer. The ownership walk must refuse the +# foreign-owned component, so no victim in outside/ is ever retagged. + +deadline = time.monotonic() + race_budget(10.0) +flip = None +try: + while time.monotonic() < deadline: + # Reset only while the flipper is quiet, so build()'s rmtree/mkdir + # cannot race the swapper and drop artifacts in outside/. + if flip is not None: + stop_flipper(flip) + flip = None + build() + flip = start_c_flipper(sub, sublink) + push() + + victim = retagged() + if victim: + test_fail( + "--backup-dir parent symlink race: victim " + f"{os.path.join(outside, victim)} was retagged away from root; rsync " + "chowned through the flipped attacker-owned backup/sub component " + "instead of refusing it." + ) +finally: + if flip is not None: + stop_flipper(flip) + subprocess.run(['rm', '-rf', bakroot], check=False) + +print("operator-path-backup-chown: backup ownership confined under parent-swap race") diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index ba499cc54..47f9992a2 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -45,6 +45,7 @@ msg-io-timeout-overflow nondaemon-symlink-race nonroot-restrictive-perms open-noatime +operator-path-backup-chown operator-path-backup-rmdir operator-path-backup-symlink operator-path-insecure-links-daemon diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 3e7653ce8..90eb4e598 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -19,6 +19,7 @@ dir-sgid fake-super-acl-xattr link-dest-symlink-enotsup # the ENOTSUP hard-link hook is an LD_PRELOAD, Linux-only open-noatime +operator-path-backup-chown partial-protected-regular-retry-linux preallocate protected-regular From 1030929cbaa19dc04176c07321ed6bb7c2bb2ff9 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 13:11:03 +1000 Subject: [PATCH 68/69] vfs: adapt the merged-in base changes to the VFS layer The base gained 13 commits. Three needed hand-porting because the change lands on code the VFS split moved or renamed: - 31130112 (--confine-root) is almost entirely in syscall.c, which does not exist here. abspath_excluded_by_module() becomes abspath_outside_confinement() in vfs/dirstack.c, taking its root from vfs.module_dir when we are a daemon and from confine_root otherwise, plus the fd-pin helpers; ona_open() in vfs/owner_walk.c gains the getcwd() seed and the pin_transit exemption. The VFS passes is_operator as an argument where the base reads operator_path_resolve, so the refusal takes it from there rather than the deleted global. - d09edb85 (--link-dest hard-link fallback) keeps the VFS call vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) and adopts the base's cannot_hardlink/match_level fallback around it. - t_stub.c gains the confine_root/confine_rootlen stubs beside the VFS curr_dir note. Tree is byte-identical to the merge oracle (tag merge-reference-11). --- generator.c | 29 +++++++++--- options.c | 2 +- receiver.c | 2 +- t_stub.c | 2 + vfs/dirstack.c | 110 ++++++++++++++++++++++++++++++++++++++------- vfs/owner_walk.c | 46 ++++++++++++++----- vfs/secure_open.c | 2 +- vfs/vfs_internal.h | 7 ++- 8 files changed, 163 insertions(+), 37 deletions(-) diff --git a/generator.c b/generator.c index 3f232155c..8b25d3707 100644 --- a/generator.c +++ b/generator.c @@ -1266,12 +1266,29 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, * hard_link_one() path above and basis_link_stat's !am_daemon gate). * fname is the transfer destination (secure receiver resolve). */ if (vfs_link_at(cmpbuf, fname, !am_daemon ? VFS_OPERATOR_PATH : 0, 0) < 0) { - rsyserr(FERROR_XFER, errno, - "failed to hard-link %s with %s", - cmpbuf, fname); - return j; - } - if (preserve_hard_links && F_IS_HLINKED(file)) + /* CAN_HARDLINK_SYMLINK/_SPECIAL answer for whatever + * filesystem the build tree sat on; the destination is + * free to disagree, and one host can hold both (macOS + * builds on APFS, backs up to HFS+). A refusal here is + * that same answer arriving late, so fall back to a copy + * as a build without the macro does -- the caller creates + * the entry either way, so failing the transfer only cost + * the exit status. + * + * Every errno, as the regular-file path next door already + * does (try_dests_reg -> hard_link_one -> try_a_copy). + * Picking out the "cannot" errnos is not possible anyway: + * link(2) documents EPERM both for a filesystem with no + * hard-link support and for an ordinary permission + * refusal, and FUSE reports ENOSYS for the same thing. + * + * The rest report themselves: ENOSPC/EDQUOT/EROFS fail the + * copy too, EMLINK and EXDEV mean it was never linkable. + * EIO alone goes unremarked, deliberately -- a diagnostic + * here lands in --link-dest's itemised output. */ + cannot_hardlink = 1; + match_level = 2; + } else if (preserve_hard_links && F_IS_HLINKED(file)) finish_hard_link(file, fname, ndx, NULL, itemizing, code, -1); } else #endif diff --git a/options.c b/options.c index 04265e33d..f4458b528 100644 --- a/options.c +++ b/options.c @@ -60,7 +60,7 @@ int preserve_executability = 0; int preserve_devices = 0; int preserve_specials = 0; int drop_devices = 0; -char *confine_root = NULL; /* --confine-root: see syscall.c */ +char *confine_root = NULL; /* --confine-root: see vfs/dirstack.c */ unsigned int confine_rootlen = 0; int preserve_uid = 0; int preserve_gid = 0; diff --git a/receiver.c b/receiver.c index c702c393c..df95f95c2 100644 --- a/receiver.c +++ b/receiver.c @@ -122,7 +122,7 @@ static int secure_basis_open(const char *basedir, const char *relpath, int flags * recv_files caller) may be absolute (module_dir-prefixed on a non-chroot * daemon) and traverse a symlink the vfs_resolve_open path can't confine: * resolve it with the ownership walk, which follows a uid0/euid-owned symlink - * but refuses a foreign one AND (via abspath_excluded_by_module) refuses a + * but refuses a foreign one AND (via abspath_outside_confinement) refuses a * target the module's exclude hides -- closing the partial-dir exclude bypass. */ if (is_operator) { char fullpath[MAXPATHLEN]; diff --git a/t_stub.c b/t_stub.c index e5c86f21c..8e1aa1e25 100644 --- a/t_stub.c +++ b/t_stub.c @@ -47,6 +47,8 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; char *partial_dir; char *module_dir; int module_dirfd = -1; +char *confine_root; +unsigned int confine_rootlen = 0; /* vfs.curr_dir[]/vfs.curr_dir_len (read by vfs_resolve_open) are defined in * syscall.c, which every helper links -- no stub needed here. */ filter_rule_list daemon_filter_list; diff --git a/vfs/dirstack.c b/vfs/dirstack.c index eb5f16f8d..bd0765c75 100644 --- a/vfs/dirstack.c +++ b/vfs/dirstack.c @@ -60,24 +60,102 @@ static int path_within(const char *root, size_t rootlen, const char *path) * name is not excluded may still resolve into an excluded IN-module subtree, * exactly as in stock rsync. The defense for a writable module is `munge * symlinks` (see rsyncd.conf(5)), not this walk. No-op unless we're a daemon. */ -int abspath_excluded_by_module(const char *abspath, int is_operator) +/* The root an operator/peer-supplied path must stay under, or NULL when nothing + * is confined. A daemon has the served module; a server launched by a wrapper + * with its own restricted directory (rrsync) gets one from --confine-root. + * + * A daemon never honours --confine-root: vfs.module_dir is the boundary there, + * and the option arrives in a peer-supplied argv, so obeying it could only + * loosen the module. */ +static const char *confinement_root(unsigned int *lenp) +{ + if (am_daemon) { + *lenp = vfs.module_dirlen; + return vfs.module_dir; + } + *lenp = confine_rootlen; + return confine_root; +} + +/* Split the "/proc//fd" prefix off `p`, returning the tail -- "" for + * the pin directory itself, otherwise a string starting with '/'. NULL when `p` + * is not in the fd-pin namespace at all. */ +const char *vfs_fd_pin_tail(const char *p) +{ + const char *s; + + if (strncmp(p, "/proc/", 6) != 0) + return NULL; + s = p + 6; + if (strncmp(s, "self/", 5) == 0) /* "/proc/self/..." */ + s += 4; + else { /* "/proc//..." */ + const char *d = s; + while (*s >= '0' && *s <= '9') + s++; + if (s == d || *s != '/') + return NULL; + } + if (strncmp(s, "/fd", 3) != 0) + return NULL; + s += 3; + return (*s == '\0' || *s == '/') ? s : NULL; +} + +/* An EXACT pin entry, "/proc/self/fd/7" -- the one spelling whose target is what + * confinement must judge. rrsync also writes a pinned parent as + * ".../fd/7/", but the walk resolves the magic link itself and checks the + * components past it, so only the bare entry is resolved here. Requiring all + * digits keeps a planted name like ".../fd/outside-secret" out. */ +static int is_exact_fd_pin(const char *p) { - if (!am_daemon || !abspath || !vfs.module_dir) + const char *tail = vfs_fd_pin_tail(p); + + if (!tail || *tail != '/') return 0; - if (vfs.module_dirlen <= 1) /* module root is "/": nothing is outside */ + for (++tail; *tail >= '0' && *tail <= '9'; tail++) {} + return *tail == '\0' && tail[-1] != '/'; +} + +int abspath_outside_confinement(const char *abspath, int is_operator) +{ + unsigned int rootlen; + const char *root = confinement_root(&rootlen); + char pinned[MAXPATHLEN]; + + if (!root || !abspath) + return 0; + if (rootlen <= 1) /* root is "/": nothing is outside */ return 0; - if (path_within(vfs.module_dir, vfs.module_dirlen, abspath)) - return 0; /* inside the module: name-based exclude is not a boundary */ - /* Not under the module root. An ABSOLUTE walk passes through the module - * root's ancestors ("/", "/home", ...) on the way down -- those are not - * "outside", just not-yet-arrived, so allow them. A path that has truly - * DIVERGED from the module tree is outside: refuse it for an operator/peer - * path that must stay in the module (is_operator); other daemon - * opens (--log-file, --*-from, lock/motd) may legitimately live elsewhere. - * The --insecure-links / "insecure links = yes" opt-out short-circuits - * before we get here. */ - if (path_within(abspath, strlen(abspath), vfs.module_dir)) - return 0; /* ancestor of the module root: still descending */ + /* An fd pin (rrsync rewrites a validated option path to /proc/self/fd/N so + * no later symlink can redirect it) is spelled outside the root by + * construction. Judge it by what it points AT rather than by its spelling, + * so a pin is neither wrongly refused nor blindly trusted. A pin we cannot + * resolve to an absolute path is refused, not waved through: an unreadable + * pin is exactly the case where we cannot say where the open would land. */ + if (!am_daemon) { + const char *tail = vfs_fd_pin_tail(abspath); + if (tail && !*tail) + return 0; /* the pin directory: transit, opens nothing */ + if (is_exact_fd_pin(abspath)) { + ssize_t n = readlink(abspath, pinned, sizeof pinned - 1); + if (n <= 0 || pinned[0] != '/') + return is_operator ? 1 : 0; + pinned[n] = '\0'; + abspath = pinned; + } + } + if (path_within(root, rootlen, abspath)) + return 0; /* inside: name-based exclude is not a boundary */ + /* Not under the root. An ABSOLUTE walk passes through the root's ancestors + * ("/", "/home", ...) on the way down -- those are not "outside", just + * not-yet-arrived, so allow them. A path that has truly DIVERGED is + * outside: refuse it for an operator/peer path that must stay in the tree + * (is_operator); other opens (--log-file, --*-from, lock/motd) may + * legitimately live elsewhere. The --insecure-links / "insecure links = + * yes" opt-out short-circuits before we get here. */ + if (!*abspath || path_within(abspath, strlen(abspath), root)) + return 0; /* ancestor of the root: still descending */ return is_operator ? 1 : 0; } @@ -197,7 +275,7 @@ int ds_descend(struct dirstack *ds, const char *part, int *hops) * symlink that redirected the walk into an excluded subtree). */ /* The strict resolver stays confined beneath the anchor (within the * module), so this never actually refuses; pass is_operator=0. */ - if (abspath_excluded_by_module(ds->abspath, 0)) { + if (abspath_outside_confinement(ds->abspath, 0)) { errno = ELOOP; return -1; } diff --git a/vfs/owner_walk.c b/vfs/owner_walk.c index 4fdcc45e2..8be7feaf3 100644 --- a/vfs/owner_walk.c +++ b/vfs/owner_walk.c @@ -24,7 +24,7 @@ /* Advance the tracked absolute path `abspath` by one resolved component, * normalizing "." and ".." exactly as openat() does so the module-confinement - * check (abspath_excluded_by_module) sees the REAL resolved target. -1/ + * check (abspath_outside_confinement) sees the REAL resolved target. -1/ * ENAMETOOLONG on overflow. */ static int abspath_step(char *abspath, size_t cap, const char *comp, size_t comp_len) { @@ -88,14 +88,36 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz int dfd = AT_FDCWD; int dfd_owns = 0; - /* Absolute module-relative path of the current dir, for the exclude-aware - * refusal (abspath_excluded_by_module). A relative operator path starts at - * the daemon's cwd == the module root; an absolute one (or a followed - * absolute symlink target) restarts at "/". */ + /* Absolute path of the current dir, for the confinement refusal + * (abspath_outside_confinement). A relative operator path starts at the + * daemon's cwd == the module root; an absolute one (or a followed absolute + * symlink target) restarts at "/". */ char abspath[MAXPATHLEN]; abspath[0] = '\0'; if (am_daemon && vfs.module_dir && vfs.module_dir[0] == '/') strlcpy(abspath, vfs.module_dir, sizeof abspath); /* "/" for a path=/ module */ + else if (confine_root) { + /* Unlike a daemon's, this cwd is not pinned to the root -- the receiver + * chdir's into the destination -- so it has to be read, not assumed. + * It must be the PHYSICAL cwd: vfs.curr_dir is the lexical name + * vfs_change_dir() was given, so after descending a trusted symlink the + * tracker sits at a different depth than the kernel, and a ".." that + * really escapes looks like it landed inside. + * + * Without it there is nothing to measure against, and an empty tracker + * does NOT deny by itself -- a leading ".." pops nothing and an empty + * path reads as an ancestor of the root -- so refuse the open instead. */ + if (!getcwd(abspath, sizeof abspath)) + return -1; + } + + /* An fd pin (rrsync rewrites an option path to /proc/self/fd/N so no + * later symlink can redirect it) is spelled outside the root by + * construction, so the walk has to be allowed through /proc/self/fd to + * reach the magic link. This only suspends the check for that prefix: + * following the link restarts the walk at its absolute target, and every + * component of THAT is checked, so a pin aimed outside is still refused. */ + int pin_transit = !am_daemon && confine_root && vfs_fd_pin_tail(path) != NULL; /* Path-walk state. `remaining` is the unconsumed tail; we splice * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a @@ -149,7 +171,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -204,6 +226,10 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz } dfd_owns = 1; abspath[0] = '\0'; /* followed an absolute target: restart from "/" */ + /* "self" resolves to "", still inside the pin; + * the magic link itself lands elsewhere and ends the + * exemption. Never turns back on. */ + pin_transit = pin_transit && vfs_fd_pin_tail(rebuilt) != NULL; char *p = rebuilt; while (*p == '/') p++; strlcpy(remaining, p, sizeof remaining); @@ -219,7 +245,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -243,7 +269,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz saved_errno = errno; goto out; } - if (abspath_excluded_by_module(abspath, is_operator)) { + if (!pin_transit && abspath_outside_confinement(abspath, is_operator)) { saved_errno = ELOOP; goto out; } @@ -337,7 +363,7 @@ int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) /* owner_walk only resolved the PARENT; check the resolved leaf too, so a * symlinked operator path cannot act on a leaf that resolves OUTSIDE the * module in an otherwise-served dir. (The module exclude/filter is name- - * based and not enforced here -- see abspath_excluded_by_module.) */ + * based and not enforced here -- see abspath_outside_confinement.) */ if (pabs[0]) { char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", pabs, *bname) >= (int)sizeof leafabs) { @@ -345,7 +371,7 @@ int vfs_owner_walk_parent(const char *path, const char **bname, int is_operator) errno = ENAMETOOLONG; /* fail closed, never skip the check */ return -1; } - if (abspath_excluded_by_module(leafabs, is_operator)) { + if (abspath_outside_confinement(leafabs, is_operator)) { close(dfd); errno = ELOOP; return -1; diff --git a/vfs/secure_open.c b/vfs/secure_open.c index c9383c873..7d18cf300 100644 --- a/vfs/secure_open.c +++ b/vfs/secure_open.c @@ -195,7 +195,7 @@ static int secure_walk_at(int anchor_fd, const char *anchor_abspath, char leafabs[MAXPATHLEN]; if (snprintf(leafabs, sizeof leafabs, "%s/%s", ds.abspath, part) < (int)sizeof leafabs - && abspath_excluded_by_module(leafabs, 0)) { + && abspath_outside_confinement(leafabs, 0)) { errno = ELOOP; goto cleanup; } diff --git a/vfs/vfs_internal.h b/vfs/vfs_internal.h index aa0046d7b..e320c0ef2 100644 --- a/vfs/vfs_internal.h +++ b/vfs/vfs_internal.h @@ -33,6 +33,8 @@ extern int copy_links; extern int copy_unsafe_links; extern int insecure_links; extern int module_id; +extern char *confine_root; /* --confine-root, or NULL; see confinement_root() */ +extern unsigned int confine_rootlen; /* Dry-run / read-only guard macros shared by the syscall wrappers. */ #define RETURN_ERROR_IF(x,e) \ @@ -52,7 +54,8 @@ extern int module_id; /* Module-confinement helpers (pure logic, always compiled). */ int path_has_dotdot_component(const char *path); -int abspath_excluded_by_module(const char *abspath, int is_operator); +int abspath_outside_confinement(const char *abspath, int is_operator); +const char *vfs_fd_pin_tail(const char *p); /* Per-operand parent resolver for the two-path ops (vfs_rename_at/vfs_link_at). */ int vfs_twopath_side(const char *path, int side_flags, const char **bname, @@ -76,7 +79,7 @@ struct dirstack { int fds[DS_MAXDEPTH]; /* fds[0] = anchor (borrowed); fds[top] = current dir */ int top; /* Absolute path of fds[top], maintained as we descend/pop, for the - * exclude-aware refusal (abspath_excluded_by_module). Empty unless the + * exclude-aware refusal (abspath_outside_confinement). Empty unless the * caller seeds it with the anchor's absolute path; then a followed symlink * that redirects the walk into a module-excluded dir is refused. */ char abspath[MAXPATHLEN]; From 2358081d3dcd652c8ca2114efb8edabb29c7c37c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 13:33:55 +1000 Subject: [PATCH 69/69] fleettest: mac2-hfs runs the backup-dir ownership race too operator-path-backup-chown probes for a cross-device directory, because make_backup() renames into a same-filesystem backup dir and never reaches the set_file_attrs() path the test is about. On macOS there is normally no such directory, so the test is a macOS-wide expected skip -- but this target puts the scratch trees on a separate HFS+ volume, which supplies exactly the condition it was looking for. Same reason backup-crossdev-copy and chmod-temp-dir are already omitted here. The test PASSES on the target (10.9s); only the expected-skip bookkeeping was wrong. --- testsuite/fleettest.json.example | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testsuite/fleettest.json.example b/testsuite/fleettest.json.example index 32c0da09a..def1b057e 100644 --- a/testsuite/fleettest.json.example +++ b/testsuite/fleettest.json.example @@ -227,9 +227,10 @@ "scratchbase": "/Volumes/RsyncHFS", "expect_skip_omit": [ "backup-crossdev-copy", - "chmod-temp-dir" + "chmod-temp-dir", + "operator-path-backup-chown" ], - "_skip_comment": "The two omitted entries are macOS-wide expected-skips that this target RUNS: the separate volume supplies the cross-device conditions mac2 lacks. (itemize used to XFAIL here, before --link-dest learned to fall back when the filesystem cannot hard-link a symlink.)" + "_skip_comment": "The three omitted entries are macOS-wide expected-skips that this target RUNS: the separate volume supplies the cross-device conditions mac2 lacks. (itemize used to XFAIL here, before --link-dest learned to fall back when the filesystem cannot hard-link a symlink.)" }, { "_comment": "The x86-64 Mac (macOS 10.13). The ONLY target that can build the x86-64 md5 assembly -- mac2 is arm64, where configure refuses --enable-md5-asm outright. MacPorts supplies autotools, python3 and the crypto/hash libs the stock 10.13 image lacks, and is not on the non-interactive ssh PATH, so put it there for the whole run. This target keeps the STOCK Apple compiler (clang 10, the 10.13 ceiling), which is what caught #161; it cannot build --enable-roll-simd, because a clang that old rejects configure's target(\"default\") multiversioning probe. That is a compiler-VERSION limit, not a Mach-O one -- see mac-x86-asm below, which builds the same source with MacPorts clang 19 and all three optimizations on.",