From f7731414b876f4f60a5784e9d7a458ff57726de7 Mon Sep 17 00:00:00 2001 From: Antoine Verheijen Date: Tue, 29 Sep 2026 02:01:00 -0600 Subject: [PATCH 1/4] Add fchmodat() as an option to set symlink Unix access permissions syscall.c: Add fchmodat(), if it exists on the system, as a last-resort attempt to change the Unix access permissions for a symlink (on those systems that support it). This is done just prior to finally abandoning such an attempt and after any other available options for this, such as lchmod() or setattrlist(). This option is required to preserve symlink access permissions when making copies using rsync on OpenBSD. The patch itself is not OS-specific. testsuite: Add a test, symlink-unix-perms_test.py, to testsuite to check on the success of setting symlink access changes using rsync. The test is skipped on those systems that do not allow symlink access permission changes. The test checks at runtime whether or not the system permits symlink access changes. As with the patch, this test is not OS-specific. --- syscall.c | 2 + testsuite/symlink-unix-perms_test.py | 79 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 testsuite/symlink-unix-perms_test.py diff --git a/syscall.c b/syscall.c index 8e96bebe4..a08f645c7 100644 --- a/syscall.c +++ b/syscall.c @@ -1735,6 +1735,8 @@ int do_chmod(const char *path, mode_t mode) break; if (errno == ENOTSUP) code = 1; +# elif defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW + code = fchmodat(AT_FDCWD, path, mode & CHMOD_BITS, AT_SYMLINK_NOFOLLOW); # else code = 1; # endif diff --git a/testsuite/symlink-unix-perms_test.py b/testsuite/symlink-unix-perms_test.py new file mode 100644 index 000000000..22719d853 --- /dev/null +++ b/testsuite/symlink-unix-perms_test.py @@ -0,0 +1,79 @@ +#!env /usr/bin/python3 -v + +import os +import random +import stat +import sys + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_mode, rmtree, run_rsync, test_fail, test_skipped +) + +# Clean out the test-specific scratch space and ensure that the 'from' +# and 'to' directories exist. + +rmtree(SCRATCHDIR) + +FROMDIR.mkdir(parents=True) +TODIR.mkdir(parents=True) + +# See if the system permits setting Unix access permssions on symlinks. +# If not, there's no point in continuing. +# +# To test this, the following code creates a symlink in the scratch space +# directory that points to the start of the scratch space directory. It +# then tries to change the Unix access permission for that link. If that +# fails, it shows that the system can't change symlink Unix access +# permissions and the remaining test process is skipped. + +os.chdir(SCRATCHDIR) + +os.symlink('.', 'symlink', target_is_directory=True) + +original = os.lstat('symlink').st_mode + +# Try to change the Unix access permissions on the symlink just created. +# +# Ignore any errors that may occur. If an error occurs, it simply means +# that the system can't set Unix access permissions on a symlink, a fact +# that will be caught when the upcoming access check shows the same +# result as the preceeding one. + +try: + os.chmod('symlink', stat.S_IREAD + stat.S_IXOTH, follow_symlinks=False) +except: + pass + +current = os.lstat('symlink').st_mode + +os.remove('symlink') + +if current == original: + test_skipped('this system is unable to set symlink Unix access permissions') + +# The system can change symlink Unix access permissions. Continue ... + +# Create a number of symlinks to use for testing rsync. +# Set the Unix access permissions for those symlinks to arbitrarily chosen +# values. Note that these permissions will be different each time the test +# is run. + +symlinks = [ f'symlink-{n}' for n in range(10) ] + +random.seed() + +for symlink in symlinks: + os.symlink('.', FROMDIR / symlink, target_is_directory=True) + os.chmod(FROMDIR / symlink, random.randrange(256) + 256, follow_symlinks=False) + +# Use rsync to copy the source directory to the destination. + +run_rsync('-av', f'{FROMDIR}/', f'{TODIR}/') + +# Finally, check to make sure that the Unix access permissions on the +# symlinks in the destination match those in the source. + +for symlink in symlinks: + expected = stat.S_IMODE(os.stat(FROMDIR / symlink, follow_symlinks=False).st_mode) + assert_mode(TODIR / symlink, expected) From 2b3d1a2ad66bd4389c7beeca9e1ee45dc1d08575 Mon Sep 17 00:00:00 2001 From: Antoine Verheijen Date: Sat, 3 Oct 2026 15:19:23 -0600 Subject: [PATCH 2/4] testsuite: Modify the symlink-unix-perms test to conform to proper standards. 1) Set the proper python shebang 2) Remove the use of random test access values in favour of fixed values. 3) Skip the test only on "not implemented" or "not supported" exceptions. All other unexpected errors will fail the test. 4) Test using symlinks to a specifically created directory or file as opposed to the '.' directory. The latter could potentially lead to cyclical paths. Some code tweaking has also been done for readability. --- testsuite/symlink-unix-perms_test.py | 130 ++++++++++++++++++--------- 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/testsuite/symlink-unix-perms_test.py b/testsuite/symlink-unix-perms_test.py index 22719d853..aa2a59beb 100644 --- a/testsuite/symlink-unix-perms_test.py +++ b/testsuite/symlink-unix-perms_test.py @@ -1,5 +1,6 @@ -#!env /usr/bin/python3 -v +#!/usr/bin/env python3 +import errno import os import random import stat @@ -10,70 +11,113 @@ assert_mode, rmtree, run_rsync, test_fail, test_skipped ) -# Clean out the test-specific scratch space and ensure that the 'from' -# and 'to' directories exist. +# Put some error messages into variable to make later code more readable. -rmtree(SCRATCHDIR) +error = 'error occurred during test of symlink access change capability: ' +notimplemented = 'symlink Unix access change is not implemented on this system' +notsupported = 'symlink Unix access change is not supported on this system' -FROMDIR.mkdir(parents=True) -TODIR.mkdir(parents=True) +# Make sure that the FROMDIR and TODIR directories exist. These are the +# source and destination directories, respectively, for the test. + +os.chdir(SCRATCHDIR) + +FROMDIR.mkdir(parents=True, exist_ok=True) +TODIR.mkdir(parents=True, exist_ok=True) + +# Create a test directory and a test file in FROMDIR for use later as symlink +# targets, saving the actual names of each in a variable that will also be +# used later. + +testdirectory = 'testdirectory' +testfile = 'testfile' + +(FROMDIR / testdirectory).mkdir(parents=True) +(FROMDIR / testfile).touch() # See if the system permits setting Unix access permssions on symlinks. # If not, there's no point in continuing. # -# To test this, the following code creates a symlink in the scratch space -# directory that points to the start of the scratch space directory. It -# then tries to change the Unix access permission for that link. If that -# fails, it shows that the system can't change symlink Unix access -# permissions and the remaining test process is skipped. - -os.chdir(SCRATCHDIR) +# To test this, the following code creates a symlink in FROMDIR that points +# to the test directory there. It then tries to change the Unix access +# permission for that symlink. If the result is a "not implemented" or a +# "not supported" error, the actual test is skipped. If any other error is +# returned, the test reports failure. -os.symlink('.', 'symlink', target_is_directory=True) +os.chdir(FROMDIR) -original = os.lstat('symlink').st_mode - -# Try to change the Unix access permissions on the symlink just created. -# -# Ignore any errors that may occur. If an error occurs, it simply means -# that the system can't set Unix access permissions on a symlink, a fact -# that will be caught when the upcoming access check shows the same -# result as the preceeding one. +os.symlink(testdirectory, 'symlink', target_is_directory=True) try: os.chmod('symlink', stat.S_IREAD + stat.S_IXOTH, follow_symlinks=False) -except: - pass -current = os.lstat('symlink').st_mode +except NotImplementedError: + test_skipped(notimplemented) -os.remove('symlink') +except OSError as e: + if (e.errno == errno.ENOTSUP): + test_skipped(notsupported) + test_fail('OS' + error + f'{e}') -if current == original: - test_skipped('this system is unable to set symlink Unix access permissions') +except Exception as e: + test_fail(error + f'{e}') -# The system can change symlink Unix access permissions. Continue ... +# The symlink access change attempt reported success. Make sure that the +# Unix access permissions were actually changed. If not, the test fails. -# Create a number of symlinks to use for testing rsync. -# Set the Unix access permissions for those symlinks to arbitrarily chosen -# values. Note that these permissions will be different each time the test -# is run. +assert_mode('symlink', stat.S_IREAD + stat.S_IXOTH) -symlinks = [ f'symlink-{n}' for n in range(10) ] +# The system can change symlink Unix access permissions. Continue ... -random.seed() +os.remove('symlink') -for symlink in symlinks: - os.symlink('.', FROMDIR / symlink, target_is_directory=True) - os.chmod(FROMDIR / symlink, random.randrange(256) + 256, follow_symlinks=False) +os.chdir(SCRATCHDIR) -# Use rsync to copy the source directory to the destination. +# Create a number of symlinks to use for testing rsync. +# +# Set the Unix access permissions for those symlinks to selected values. +# The selected values have no specific agenda. However, owner read access +# is always maintained to ensure that stat info is readable. + +# The following array consists of a number of targets and associated Unix +# access permissions that will be used in the actual test. The symlink +# names will be of the form "symlink-{n}" where "{n}" is the array index +# for each symlink created. + +symlinks = [ + [ testdirectory, 0o770 ], + [ testfile, 0o0444 ], + [ testfile, 0o0711 ], + [ testfile, 0o0777 ], + [ testdirectory, 0o7777 ], + [ testfile, 0o0400 ], + [ testdirectory, 0o1407 ], + [ testdirectory, 0o2470 ], + [ testdirectory, 0o4777 ], + [ testfile, 0o7777 ] +] + +# Actually create the test symlinks in the source directory. + +for n, symlink in enumerate(symlinks): + target, access = symlink + + if target == 'directory': + isdir = True + else: + isdir = False + + os.symlink(f'{target}', FROMDIR / f'symlink-{n}', target_is_directory=f'{isdir}') + os.chmod(FROMDIR / f'symlink-{n}', access, follow_symlinks=False) + +# Now, use rsync to copy the source directory to the destination. run_rsync('-av', f'{FROMDIR}/', f'{TODIR}/') # Finally, check to make sure that the Unix access permissions on the -# symlinks in the destination match those in the source. +# symlinks in the destination match those in the source. The test fail +# if any one doesn't match. -for symlink in symlinks: - expected = stat.S_IMODE(os.stat(FROMDIR / symlink, follow_symlinks=False).st_mode) - assert_mode(TODIR / symlink, expected) +for n, symlink in enumerate(symlinks): + expected = stat.S_IMODE(os.stat(FROMDIR / f'symlink-{n}', follow_symlinks=False).st_mode) + assert_mode(TODIR / f'symlink-{n}', expected) From a6da76cc4b20063021d1a77b6a06e79572562360 Mon Sep 17 00:00:00 2001 From: Antoine Verheijen Date: Mon, 5 Oct 2026 00:31:52 -0600 Subject: [PATCH 3/4] symlink-unix-perms_test.py: Perform some useful cleanup. 1) Remove unnecessary import of "random". 2) Consolidate some unnecessarily verbose and inefficient code. --- testsuite/symlink-unix-perms_test.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/testsuite/symlink-unix-perms_test.py b/testsuite/symlink-unix-perms_test.py index aa2a59beb..9827a6bf2 100644 --- a/testsuite/symlink-unix-perms_test.py +++ b/testsuite/symlink-unix-perms_test.py @@ -2,7 +2,6 @@ import errno import os -import random import stat import sys @@ -94,7 +93,7 @@ [ testdirectory, 0o1407 ], [ testdirectory, 0o2470 ], [ testdirectory, 0o4777 ], - [ testfile, 0o7777 ] + [ testfile, 0o7777 ], ] # Actually create the test symlinks in the source directory. @@ -102,12 +101,7 @@ for n, symlink in enumerate(symlinks): target, access = symlink - if target == 'directory': - isdir = True - else: - isdir = False - - os.symlink(f'{target}', FROMDIR / f'symlink-{n}', target_is_directory=f'{isdir}') + os.symlink(target, FROMDIR / f'symlink-{n}', target_is_directory=(target == testdirectory)) os.chmod(FROMDIR / f'symlink-{n}', access, follow_symlinks=False) # Now, use rsync to copy the source directory to the destination. From bf1476a2ce01a5c1eae11c4c012427cc1019a4d4 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Tue, 6 Oct 2026 08:16:09 +1000 Subject: [PATCH 4/4] testsuite: expect unsupported symlink permission skips --- testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/linux.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 4b984a322..53e0e9e63 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -68,4 +68,5 @@ source-change-size-continues symlink-dest-backupdir symlink-race-dest symlink-race-relative-dest +symlink-unix-perms # platform does not support changing symlink permissions temp-dir-symlink-injection diff --git a/testsuite/skiplist/linux.txt b/testsuite/skiplist/linux.txt index b89100d26..d9c543411 100644 --- a/testsuite/skiplist/linux.txt +++ b/testsuite/skiplist/linux.txt @@ -8,3 +8,4 @@ crtimes # Rsync is configured without crtimes support partial-protected-regular-retry-policy # deterministic partial EACCES recovery uses dyld interposing readonly-partial-abort-mode-regression # +symlink-unix-perms # platform does not support changing symlink permissions