From ec944345b3383cd421bbd281419d4c7ff1bf3022 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Mon, 14 Sep 2026 22:07:36 +1000 Subject: [PATCH 1/3] syscall: follow trusted sender ancestor links --- syscall.c | 25 ++++++++++ t_secure_relpath.c | 58 ++++++++++++++++++++++ testsuite/relative-source-ancestor_test.py | 56 +++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 testsuite/relative-source-ancestor_test.py diff --git a/syscall.c b/syscall.c index 81e12f906..d061c7326 100644 --- a/syscall.c +++ b/syscall.c @@ -3246,6 +3246,31 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo flags |= O_NOATIME; #endif +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + if (!am_daemon && am_sender && basedir && strcmp(basedir, "/") == 0 && *relpath) { + /* Absolute sender names retain their ancestors with --relative. Follow + * trusted-owned ancestor symlinks, not a RESOLVE_BENEATH walk that rejects + * /mnt/home -> /initrd/mnt/dev_save. Daemon and cwd anchors stay confined. */ + char fullpath[MAXPATHLEN]; + const char *bname; + int dfd, fd, saved_errno; + if (snprintf(fullpath, sizeof fullpath, "/%s", relpath) >= (int)sizeof fullpath) { + errno = ENAMETOOLONG; + return -1; + } + if (flags & O_DIRECTORY) + return open_no_attacker_symlinks(fullpath, flags, mode); + dfd = owner_walk_parent(fullpath, &bname); + if (dfd < 0) + return -1; + fd = openat(dfd, bname, flags | O_NOFOLLOW, mode); + saved_errno = errno; + close(dfd); + errno = saved_errno; + return fd; + } +#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) { diff --git a/t_secure_relpath.c b/t_secure_relpath.c index d20570d61..74e578614 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -173,6 +173,63 @@ static void check_beneath_dotdot(void) close(anchor); } +static void check_sender_absolute_ancestor(void) +{ +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + char cwd[MAXPATHLEN], target[MAXPATHLEN], path[MAXPATHLEN]; + int fd; + if (!getcwd(cwd, sizeof cwd) + || snprintf(target, sizeof target, "%s/subdir", cwd) >= (int)sizeof target + || snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path + || symlink(target, "absolute-alias") < 0) { + perror("absolute ancestor fixture"); + errs++; + return; + } + am_daemon = 0; + am_sender = 1; + fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0); + if (fd < 0) { + perror("trusted absolute sender ancestor"); + errs++; + } else + close(fd); + fd = secure_relative_open(NULL, "absolute-alias", O_RDONLY | O_DIRECTORY, 0); + if (fd >= 0 || errno != ELOOP) { + fprintf(stderr, "FAIL [cwd sender ancestor]: rc=%d errno=%d\n", fd, errno); + if (fd >= 0) + close(fd); + errs++; + } + if (symlink("missing", "subdir/leaf-link") < 0 + || snprintf(path, sizeof path, "%s/absolute-alias/leaf-link", cwd) >= (int)sizeof path) { + perror("sender leaf fixture"); + errs++; + } else { + fd = secure_relative_open("/", path + 1, O_RDONLY, 0); + if (fd >= 0 || errno != ELOOP) { + fprintf(stderr, "FAIL [absolute sender leaf]: rc=%d errno=%d\n", fd, errno); + if (fd >= 0) + close(fd); + errs++; + } + } + am_daemon = 1; + am_sender = 0; + if (snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path) { + errs++; + return; + } + fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0); + if (fd >= 0 || errno != ELOOP) { + fprintf(stderr, "FAIL [daemon absolute ancestor]: rc=%d errno=%d\n", fd, errno); + if (fd >= 0) + close(fd); + errs++; + } +#endif +} + int main(int argc, char **argv) { if (argc != 2) { @@ -224,6 +281,7 @@ int main(int argc, char **argv) * literal '..'. Its dedicated fd-anchored entry point must preserve an * in-tree climb while refusing to pop above the anchor. */ check_beneath_dotdot(); + check_sender_absolute_ancestor(); if (errs) fprintf(stderr, "\n%d failure(s)\n", errs); diff --git a/testsuite/relative-source-ancestor_test.py b/testsuite/relative-source-ancestor_test.py new file mode 100644 index 000000000..8c4907f51 --- /dev/null +++ b/testsuite/relative-source-ancestor_test.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Absolute --relative sources with a trusted-owned ancestor symlink.""" + +import os +import pwd +import subprocess + +from rsyncfns import SCRATCHDIR, rsync_argv, test_fail + +real = SCRATCHDIR / 'real' +source = real / 'My_Documents' +source.mkdir(parents=True) +(source / 'marker').write_text('source contents\n') +link = SCRATCHDIR / 'home' +os.symlink(str(real), link) + +for index, options in enumerate((('-a',), ('-aR',), ('-aR', '--no-inc-recursive'))): + for trailing in ('', '/'): + dest = SCRATCHDIR / f'dest-{index}-{bool(trailing)}' + dest.mkdir() + proc = subprocess.run( + rsync_argv(*options, str(link / 'My_Documents') + trailing, str(dest) + '/'), + capture_output=True, text=True, + ) + if proc.returncode: + test_fail(f'trusted ancestor transfer failed: {proc.stdout}{proc.stderr}') + if '-aR' in options: + expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker' + else: + expected = dest / ('' if trailing else 'My_Documents') / 'marker' + if not expected.is_file() or expected.read_text() != 'source contents\n': + test_fail('relative source layout or contents changed') + +dest = SCRATCHDIR / 'remove-dest' +dest.mkdir() +proc = subprocess.run( + rsync_argv('-aR', '--remove-source-files', str(link / 'My_Documents') + '/', str(dest) + '/'), + capture_output=True, text=True, +) +expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker' +if proc.returncode or (source / 'marker').exists() or not expected.is_file(): + test_fail(f'remove-source-files failed: {proc.stdout}{proc.stderr}') +(source / 'marker').write_text('source contents\n') + +if os.geteuid() == 0: + attacker = next((entry.pw_uid for entry in pwd.getpwall() if entry.pw_uid != 0), None) + if attacker is not None: + os.lchown(link, attacker, -1) + dest = SCRATCHDIR / 'untrusted-dest' + dest.mkdir() + proc = subprocess.run( + rsync_argv('-aR', str(link / 'My_Documents') + '/', str(dest) + '/'), + capture_output=True, text=True, + ) + if proc.returncode == 0 or any(dest.rglob('marker')): + test_fail('an untrusted ancestor symlink was followed') From 918ff435188c4700604b3f368c8ee1fcdd723143 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Mon, 14 Sep 2026 22:22:46 +1000 Subject: [PATCH 2/3] testsuite: accept BSD symlink errors --- t_secure_relpath.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/t_secure_relpath.c b/t_secure_relpath.c index 74e578614..11c705980 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -207,7 +207,11 @@ static void check_sender_absolute_ancestor(void) errs++; } else { fd = secure_relative_open("/", path + 1, O_RDONLY, 0); - if (fd >= 0 || errno != ELOOP) { + if (fd >= 0 || (errno != ELOOP && errno != EMLINK +#ifdef EFTYPE + && errno != EFTYPE +#endif + )) { fprintf(stderr, "FAIL [absolute sender leaf]: rc=%d errno=%d\n", fd, errno); if (fd >= 0) close(fd); From d0a5ab7fc636c7e155dd986ad9821ef883749c0e Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Tue, 15 Sep 2026 17:06:25 +1000 Subject: [PATCH 3/3] syscall: anchor sender paths at explicit source roots --- flist.c | 121 ++++++++++++++++++++- sender.c | 7 +- syscall.c | 25 ----- t_secure_relpath.c | 63 ----------- testsuite/relative-source-ancestor_test.py | 105 +++++++++++++----- 5 files changed, 202 insertions(+), 119 deletions(-) diff --git a/flist.c b/flist.c index 9276c65fc..9b2b17494 100644 --- a/flist.c +++ b/flist.c @@ -230,6 +230,117 @@ static int scan_dirfd = -1; static const char *scan_dir_prefix; static int scan_dir_prefix_len; +struct sender_source_root { + struct sender_source_root *next; + dev_t dev; + ino_t ino; + char path[1]; +}; + +static struct sender_source_root *sender_source_roots; + +static int sender_source_full_path(const char *path, char *full, size_t full_size) +{ + size_t len; + + if (*path == '/') + len = strlcpy(full, path, full_size); + else + len = pathjoin(full, full_size, curr_dir, path); + if (len >= full_size) { + errno = ENAMETOOLONG; + return -1; + } + clean_fname(full, CFN_COLLAPSE_DOT_DOT_DIRS | CFN_DROP_TRAILING_DOT_DIR); + return 0; +} + +static void remember_sender_source_root(const char *path, const STRUCT_STAT *st) +{ + struct sender_source_root *root; + char full[MAXPATHLEN]; + size_t len; + + if (sender_source_full_path(path, full, sizeof full) < 0) + overflow_exit("remember_sender_source_root"); + len = strlen(full); + + for (root = sender_source_roots; root; root = root->next) { + if (strcmp(root->path, full) == 0) + return; + } + root = (struct sender_source_root *)new_array(char, sizeof *root + len); + root->next = sender_source_roots; + root->dev = st->st_dev; + root->ino = st->st_ino; + memcpy(root->path, full, len + 1); + sender_source_roots = root; +} + +int open_sender_source_path(const char *path, int flags, int *matched) +{ +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + struct sender_source_root *root, *best = NULL; + STRUCT_STAT st; + char full[MAXPATHLEN], *rel; + size_t best_len = 0; + int rootfd, fd, saved_errno; + + *matched = 0; + if (!sender_source_roots) + return -1; + if (sender_source_full_path(path, full, sizeof full) < 0) + return -1; + for (root = sender_source_roots; root; root = root->next) { + size_t len = strlen(root->path); + if (len > best_len && strncmp(full, root->path, len) == 0 + && (root->path[len-1] == '/' || full[len] == '\0' || full[len] == '/')) { + best = root; + best_len = len; + } + } + if (!best) + return -1; + + *matched = 1; + rootfd = open(best->path, O_RDONLY | O_DIRECTORY); + if (rootfd < 0) + return -1; + if (do_fstat(rootfd, &st) < 0) + saved_errno = errno; + else if (st.st_dev != best->dev || st.st_ino != best->ino) + saved_errno = ELOOP; + else + saved_errno = 0; + if (saved_errno) { + close(rootfd); + errno = saved_errno; + return -1; + } + rel = full + best_len; + while (*rel == '/') + rel++; + fd = secure_relative_open_at(rootfd, *rel ? rel : ".", flags, 0); + saved_errno = errno; + close(rootfd); + errno = saved_errno; + return fd; +#else + *matched = 0; + errno = ENOSYS; + return -1; +#endif +} + +void clear_sender_source_roots(void) +{ + while (sender_source_roots) { + struct sender_source_root *root = sender_source_roots; + sender_source_roots = root->next; + free(root); + } +} + static int scan_link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlinks) { /* Use the held scan fd only for a single component directly inside the @@ -2028,10 +2139,14 @@ static void interpret_stat_error(const char *fname, int is_dir) * Returns NULL with errno set on failure, like opendir(). */ static DIR *secure_opendir(const char *fbuf) { - int dfd, fl; + int dfd, fl, matched; DIR *d; - if (am_daemon && (!am_chrooted || module_dirlen) + if (!am_daemon && am_sender + && (dfd = open_sender_source_path(fbuf, O_RDONLY | O_DIRECTORY, &matched), matched)) { + /* The command-line directory is the operator-selected transfer root. + * Follow that root, then keep every recursive scan beneath its held fd. */ + } else 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 @@ -2724,6 +2839,8 @@ struct file_list *send_file_list(int f, int argc, char *argv[]) rprintf(FINFO, "skipping directory %s\n", fbuf); continue; } + if (!am_daemon && !use_ff_fd && S_ISDIR(st.st_mode)) + remember_sender_source_root(fbuf, &st); if (inc_recurse && relative_paths && *fbuf) { if ((p = strchr(fbuf+1, '/')) != NULL) { diff --git a/sender.c b/sender.c index ba40b9468..4890e30b2 100644 --- a/sender.c +++ b/sender.c @@ -680,6 +680,7 @@ void send_files(int f_in, int f_out) else fd = sender_open_confined(module_dir, relp, O_RDONLY); } else if (!copy_links && !copy_unsafe_links && !copy_dirlinks && !insecure_links) { + int matched; /* Default symlink handling (no dir-link following): the scan * recorded this as a regular file. Open it confined beneath the * transfer root: an in-tree symlinked parent (e.g. -R keeps one in @@ -688,7 +689,10 @@ void send_files(int f_in, int f_out) * governs the leaf so a raced leaf symlink is refused. A * symlink-following mode (-L/--copy-unsafe-links/-k) or * --insecure-links keeps the legacy open below. */ - if (fname[0] == '/') { + fd = open_sender_source_path(fname, O_RDONLY | O_NOFOLLOW, &matched); + if (matched) { + /* The explicit source directory is the trust root. */ + } else if (fname[0] == '/') { /* --relative (or a --files-from absolute name) keeps the * full absolute path as fname; the transfer root is then "/", * so anchor the confined open there and strip the leading @@ -809,6 +813,7 @@ void send_files(int f_in, int f_out) if (DEBUG_GTE(SEND, 1)) rprintf(FINFO, "send files finished\n"); + clear_sender_source_roots(); match_report(); write_ndx(f_out, NDX_DONE); diff --git a/syscall.c b/syscall.c index d061c7326..81e12f906 100644 --- a/syscall.c +++ b/syscall.c @@ -3246,31 +3246,6 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo flags |= O_NOATIME; #endif -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY - if (!am_daemon && am_sender && basedir && strcmp(basedir, "/") == 0 && *relpath) { - /* Absolute sender names retain their ancestors with --relative. Follow - * trusted-owned ancestor symlinks, not a RESOLVE_BENEATH walk that rejects - * /mnt/home -> /initrd/mnt/dev_save. Daemon and cwd anchors stay confined. */ - char fullpath[MAXPATHLEN]; - const char *bname; - int dfd, fd, saved_errno; - if (snprintf(fullpath, sizeof fullpath, "/%s", relpath) >= (int)sizeof fullpath) { - errno = ENAMETOOLONG; - return -1; - } - if (flags & O_DIRECTORY) - return open_no_attacker_symlinks(fullpath, flags, mode); - dfd = owner_walk_parent(fullpath, &bname); - if (dfd < 0) - return -1; - fd = openat(dfd, bname, flags | O_NOFOLLOW, mode); - saved_errno = errno; - close(dfd); - errno = saved_errno; - return fd; - } -#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) { diff --git a/t_secure_relpath.c b/t_secure_relpath.c index 11c705980..184acef67 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -173,67 +173,6 @@ static void check_beneath_dotdot(void) close(anchor); } -static void check_sender_absolute_ancestor(void) -{ -#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY - char cwd[MAXPATHLEN], target[MAXPATHLEN], path[MAXPATHLEN]; - int fd; - if (!getcwd(cwd, sizeof cwd) - || snprintf(target, sizeof target, "%s/subdir", cwd) >= (int)sizeof target - || snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path - || symlink(target, "absolute-alias") < 0) { - perror("absolute ancestor fixture"); - errs++; - return; - } - am_daemon = 0; - am_sender = 1; - fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0); - if (fd < 0) { - perror("trusted absolute sender ancestor"); - errs++; - } else - close(fd); - fd = secure_relative_open(NULL, "absolute-alias", O_RDONLY | O_DIRECTORY, 0); - if (fd >= 0 || errno != ELOOP) { - fprintf(stderr, "FAIL [cwd sender ancestor]: rc=%d errno=%d\n", fd, errno); - if (fd >= 0) - close(fd); - errs++; - } - if (symlink("missing", "subdir/leaf-link") < 0 - || snprintf(path, sizeof path, "%s/absolute-alias/leaf-link", cwd) >= (int)sizeof path) { - perror("sender leaf fixture"); - errs++; - } else { - fd = secure_relative_open("/", path + 1, O_RDONLY, 0); - if (fd >= 0 || (errno != ELOOP && errno != EMLINK -#ifdef EFTYPE - && errno != EFTYPE -#endif - )) { - fprintf(stderr, "FAIL [absolute sender leaf]: rc=%d errno=%d\n", fd, errno); - if (fd >= 0) - close(fd); - errs++; - } - } - am_daemon = 1; - am_sender = 0; - if (snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path) { - errs++; - return; - } - fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0); - if (fd >= 0 || errno != ELOOP) { - fprintf(stderr, "FAIL [daemon absolute ancestor]: rc=%d errno=%d\n", fd, errno); - if (fd >= 0) - close(fd); - errs++; - } -#endif -} - int main(int argc, char **argv) { if (argc != 2) { @@ -285,8 +224,6 @@ int main(int argc, char **argv) * literal '..'. Its dedicated fd-anchored entry point must preserve an * in-tree climb while refusing to pop above the anchor. */ check_beneath_dotdot(); - check_sender_absolute_ancestor(); - if (errs) fprintf(stderr, "\n%d failure(s)\n", errs); return errs ? 1 : 0; diff --git a/testsuite/relative-source-ancestor_test.py b/testsuite/relative-source-ancestor_test.py index 8c4907f51..1bcc1baf7 100644 --- a/testsuite/relative-source-ancestor_test.py +++ b/testsuite/relative-source-ancestor_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Absolute --relative sources with a trusted-owned ancestor symlink.""" +"""Explicit source directory symlinks remain transfer roots.""" import os import pwd @@ -11,46 +11,95 @@ source = real / 'My_Documents' source.mkdir(parents=True) (source / 'marker').write_text('source contents\n') -link = SCRATCHDIR / 'home' -os.symlink(str(real), link) +absolute_link = SCRATCHDIR / 'home' +relative_link = SCRATCHDIR / 'relative-home' +os.symlink(str(real), absolute_link) +os.symlink(str(real), relative_link) -for index, options in enumerate((('-a',), ('-aR',), ('-aR', '--no-inc-recursive'))): - for trailing in ('', '/'): - dest = SCRATCHDIR / f'dest-{index}-{bool(trailing)}' +cases = ( + (('-r',), False), + (('-rR',), True), + (('-rR', '--no-inc-recursive'), True), +) + +for link_name, link, cwd in ( + ('absolute', absolute_link, None), + ('relative', relative_link, SCRATCHDIR), +): + source_arg = (str(SCRATCHDIR) + '/./home/My_Documents/' if cwd is None + else 'relative-home/My_Documents/') + for index, (options, relative) in enumerate(cases): + dest = SCRATCHDIR / f'dest-{link_name}-descendant-{index}' dest.mkdir() proc = subprocess.run( - rsync_argv(*options, str(link / 'My_Documents') + trailing, str(dest) + '/'), - capture_output=True, text=True, + rsync_argv(*options, source_arg, str(dest) + '/'), + cwd=cwd, capture_output=True, text=True, ) if proc.returncode: - test_fail(f'trusted ancestor transfer failed: {proc.stdout}{proc.stderr}') - if '-aR' in options: - expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker' - else: - expected = dest / ('' if trailing else 'My_Documents') / 'marker' + test_fail(f'{link_name} descendant source transfer with {options} ' + f'failed: {proc.stdout}{proc.stderr}') + expected = (dest / link.name / 'My_Documents' / 'marker' if relative + else dest / 'marker') if not expected.is_file() or expected.read_text() != 'source contents\n': test_fail('relative source layout or contents changed') +root_real = SCRATCHDIR / 'root-real' +root_real.mkdir() +(root_real / 'marker').write_text('source contents\n') +absolute_root_link = SCRATCHDIR / 'root-home' +relative_root_link = SCRATCHDIR / 'relative-root-home' +os.symlink(str(root_real), absolute_root_link) +os.symlink(str(root_real), relative_root_link) + +for link_name, link, cwd in ( + ('absolute', absolute_root_link, None), + ('relative', relative_root_link, SCRATCHDIR), +): + source_arg = (str(SCRATCHDIR) + '/./root-home/' if cwd is None + else 'relative-root-home/') + for index, (options, relative) in enumerate(cases): + dest = SCRATCHDIR / f'dest-{link_name}-root-{index}' + dest.mkdir() + proc = subprocess.run( + rsync_argv(*options, source_arg, str(dest) + '/'), + cwd=cwd, capture_output=True, text=True, + ) + if proc.returncode: + test_fail(f'{link_name} root source transfer with {options} failed: ' + f'{proc.stdout}{proc.stderr}') + expected = dest / link.name / 'marker' if relative else dest / 'marker' + if not expected.is_file() or expected.read_text() != 'source contents\n': + test_fail('explicit source-root layout or contents changed') + +if os.geteuid() == 0: + untrusted_uid = next((entry.pw_uid for entry in pwd.getpwall() + if entry.pw_uid != 0), None) + if untrusted_uid is not None: + untrusted_link = SCRATCHDIR / 'untrusted-home' + os.symlink(str(real), untrusted_link) + os.lchown(untrusted_link, untrusted_uid, -1) + source_arg = str(SCRATCHDIR) + '/./untrusted-home/' + for index, (options, relative) in enumerate(cases[:2]): + dest = SCRATCHDIR / f'untrusted-dest-{index}' + dest.mkdir() + proc = subprocess.run( + rsync_argv(*options, source_arg, str(dest) + '/'), + capture_output=True, text=True, + ) + expected = dest / 'My_Documents' / 'marker' + if relative: + expected = dest / 'untrusted-home' / 'My_Documents' / 'marker' + if proc.returncode or not expected.is_file(): + test_fail(f'explicit untrusted-owned source link with {options} ' + f'failed: {proc.stdout}{proc.stderr}') + dest = SCRATCHDIR / 'remove-dest' dest.mkdir() proc = subprocess.run( - rsync_argv('-aR', '--remove-source-files', str(link / 'My_Documents') + '/', str(dest) + '/'), + rsync_argv('-r', '--remove-source-files', str(absolute_link / 'My_Documents') + '/', str(dest) + '/'), capture_output=True, text=True, ) -expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker' +expected = dest / 'marker' if proc.returncode or (source / 'marker').exists() or not expected.is_file(): test_fail(f'remove-source-files failed: {proc.stdout}{proc.stderr}') (source / 'marker').write_text('source contents\n') - -if os.geteuid() == 0: - attacker = next((entry.pw_uid for entry in pwd.getpwall() if entry.pw_uid != 0), None) - if attacker is not None: - os.lchown(link, attacker, -1) - dest = SCRATCHDIR / 'untrusted-dest' - dest.mkdir() - proc = subprocess.run( - rsync_argv('-aR', str(link / 'My_Documents') + '/', str(dest) + '/'), - capture_output=True, text=True, - ) - if proc.returncode == 0 or any(dest.rglob('marker')): - test_fail('an untrusted ancestor symlink was followed')