diff --git a/flist.c b/flist.c index 9276c65fc..2c81d3d45 100644 --- a/flist.c +++ b/flist.c @@ -46,6 +46,7 @@ extern int recurse; extern int use_qsort; extern int xfer_dirs; extern int filesfrom_fd; +extern char *files_from; extern int one_file_system; extern int copy_devices; extern int copy_dirlinks; @@ -230,6 +231,189 @@ 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; +} + +static void remember_sender_source_arg(const char *path, const STRUCT_STAT *st) +{ + STRUCT_STAT parent_st; + char full[MAXPATHLEN], *slash; + + if (S_ISDIR(st->st_mode)) { + remember_sender_source_root(path, st); + return; + } + if (sender_source_full_path(path, full, sizeof full) < 0) + overflow_exit("remember_sender_source_arg"); + slash = strrchr(full, '/'); + if (!slash) + return; + if (slash == full) + slash[1] = '\0'; + else + *slash = '\0'; + /* The operator selected this parent as part of the source argument. Pin + * its resolved identity while the file leaf remains O_NOFOLLOW later. */ + if (do_stat(full, &parent_st) == 0 && S_ISDIR(parent_st.st_mode)) + remember_sender_source_root(full, &parent_st); +} + +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_anchor_dirfd(best->path); + 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 filesfrom_owner_walk_active(void) +{ + return !am_daemon && am_sender && files_from + && !copy_links && !copy_unsafe_links && !copy_dirlinks && !insecure_links; +} + +static int filesfrom_link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlinks) +{ +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + const char *bname; + int dfd, ret, save_errno; + + dfd = owner_walk_parent(path, &bname); + if (dfd < 0) + return -1; + if (am_root < 0) { + close(dfd); + return link_stat(path, stp, follow_dirlinks); + } + ret = link_stat_at(dfd, bname, stp, follow_dirlinks); + save_errno = ret < 0 ? errno : 0; + close(dfd); + errno = save_errno; + return ret; +#else + return link_stat(path, stp, follow_dirlinks); +#endif +} + +static int filesfrom_readlink(const char *path, char *linkbuf, size_t bufsiz) +{ +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + const char *bname; + int dfd, ret, save_errno; + + dfd = owner_walk_parent(path, &bname); + if (dfd < 0) + return -1; + ret = do_readlink_atfd(dfd, bname, linkbuf, bufsiz); + save_errno = ret < 0 ? errno : 0; + close(dfd); + errno = save_errno; + return ret; +#else + return do_readlink(path, linkbuf, bufsiz); +#endif +} + 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 @@ -241,6 +425,8 @@ static int scan_link_stat(const char *path, STRUCT_STAT *stp, int follow_dirlink && path[scan_dir_prefix_len] == '/' && strchr(path + scan_dir_prefix_len + 1, '/') == NULL) return link_stat_at(scan_dirfd, path + scan_dir_prefix_len + 1, stp, follow_dirlinks); + if (filesfrom_owner_walk_active()) + return filesfrom_link_stat(path, stp, follow_dirlinks); return link_stat(path, stp, follow_dirlinks); } @@ -251,6 +437,8 @@ static int scan_readlink(const char *path, char *linkbuf, size_t bufsiz) && 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); + if (filesfrom_owner_walk_active()) + return filesfrom_readlink(path, linkbuf, bufsiz); return do_readlink(path, linkbuf, bufsiz); } @@ -2014,7 +2202,7 @@ 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. +/* Open a source directory for scanning under the applicable source authority. * secure_relative_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 @@ -2025,13 +2213,24 @@ static void interpret_stat_error(const char *fname, int is_dir) * O_NOFOLLOW makes secure_relative_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). + * Files-from entries instead use the ownership walk: their source base is + * operator-selected, but each list entry may not be, so only trusted-owned + * symlinks are followed and a trusted link may retain its legacy target. * 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 (filesfrom_owner_walk_active()) { + /* The source base is operator-selected, while each list entry may not + * be. Follow only trusted-owned symlinks while opening the directory. */ + dfd = open_no_attacker_symlinks(fbuf, O_RDONLY | O_DIRECTORY, 0); + } else 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 @@ -2333,7 +2532,7 @@ static void send1extra(int f, struct file_struct *file, struct file_list *flist) if (file->flags & FLAG_CONTENT_DIR) { if (one_file_system) { STRUCT_STAT st; - if (link_stat(fbuf, &st, copy_dirlinks) != 0) { + if (scan_link_stat(fbuf, &st, copy_dirlinks) != 0) { interpret_stat_error(fbuf, True); return; } @@ -2369,7 +2568,7 @@ static void send1extra(int f, struct file_struct *file, struct file_list *flist) if (name_type != NORMAL_NAME) { STRUCT_STAT st = {0}; - if (name_type != MISSING_NAME && link_stat(fbuf, &st, 1) != 0) { + if (name_type != MISSING_NAME && scan_link_stat(fbuf, &st, 1) != 0) { interpret_stat_error(fbuf, True); continue; } @@ -2694,7 +2893,7 @@ struct file_list *send_file_list(int f, int argc, char *argv[]) if (fn != fbuf) memmove(fbuf, fn, len + 1); - if (link_stat(fbuf, &st, copy_dirlinks || name_type != NORMAL_NAME) != 0 + if (scan_link_stat(fbuf, &st, copy_dirlinks || name_type != NORMAL_NAME) != 0 || (name_type != DOTDIR_NAME && is_excluded(fbuf, S_ISDIR(st.st_mode) != 0, SERVER_FILTERS)) || (relative_paths && path_is_daemon_excluded(fbuf, 1))) { if (errno != ENOENT || missing_args == 0) { @@ -2724,6 +2923,9 @@ 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 && st.st_mode != 0 + && (relative_paths || S_ISDIR(st.st_mode))) + remember_sender_source_arg(fbuf, &st); if (inc_recurse && relative_paths && *fbuf) { if ((p = strchr(fbuf+1, '/')) != NULL) { diff --git a/sender.c b/sender.c index ba40b9468..88d7b7c30 100644 --- a/sender.c +++ b/sender.c @@ -56,6 +56,7 @@ extern char *module_dir; extern int module_dirfd; extern int write_batch; extern int file_old_total; +extern char *files_from; extern BOOL want_progress_now; extern struct stats stats; extern struct file_list *cur_flist, *first_flist, *dir_flist; @@ -680,26 +681,27 @@ 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) { - /* 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 - * the path) is followed beneath the root, a parent raced into a - * symlink pointing out of the tree is refused, and O_NOFOLLOW - * 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] == '/') { - /* --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 - * slash to the module-relative path the resolver wants -- it - * rejects an absolute relpath outright. */ - const char *relp = fname; - while (*relp == '/') - relp++; - fd = sender_open_confined("/", relp, O_RDONLY); - } else - fd = sender_open_confined(NULL, fname, O_RDONLY); + int matched; + /* A files-from entry follows only trusted-owned ancestors because + * its source base is operator-selected but the entry itself may not + * be. Other paths stay confined beneath their explicit transfer root. + * Every file leaf remains O_NOFOLLOW. */ + if (files_from) { + fd = do_open_checklinks(fname); + } else { + fd = open_sender_source_path(fname, O_RDONLY | O_NOFOLLOW, &matched); + if (!matched) { + if (fname[0] == '/') { + /* --relative keeps the full absolute path as fname; + * anchor at "/" and pass the resolver a relative path. */ + const char *relp = fname; + while (*relp == '/') + relp++; + fd = sender_open_confined("/", relp, O_RDONLY); + } else + fd = sender_open_confined(NULL, fname, O_RDONLY); + } + } } else { fd = do_open_checklinks(fname); } @@ -809,6 +811,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 81e12f906..421ae4b0f 100644 --- a/syscall.c +++ b/syscall.c @@ -62,6 +62,7 @@ extern int preserve_executability; extern int open_noatime; extern int copy_links; extern int copy_unsafe_links; +extern int copy_dirlinks; extern int am_daemon; extern int am_chrooted; extern int insecure_links; @@ -69,6 +70,7 @@ 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 *files_from; 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 */ @@ -96,7 +98,7 @@ static int directory_traverse_flags(void) * 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) +int open_anchor_dirfd(const char *path) { if (module_dirfd >= 0 && am_daemon && module_dir && strcmp(path, module_dir) == 0) return dup(module_dirfd); @@ -3545,6 +3547,22 @@ int do_open_checklinks(const char *pathname) if (copy_links || copy_unsafe_links) { return do_open(pathname, O_RDONLY, 0); } +#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY + if (am_sender && !am_daemon && files_from + && !copy_dirlinks && !symlink_optout_allowed()) { + const char *bname; + int dfd, fd, save_errno; + + dfd = owner_walk_parent(pathname, &bname); + if (dfd < 0) + return -1; + fd = openat(dfd, bname, O_RDONLY | O_NOFOLLOW, 0); + save_errno = fd < 0 ? errno : 0; + close(dfd); + errno = save_errno; + return fd; + } +#endif return do_open_nofollow(pathname, O_RDONLY); } diff --git a/t_secure_relpath.c b/t_secure_relpath.c index d20570d61..184acef67 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -224,7 +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(); - if (errs) fprintf(stderr, "\n%d failure(s)\n", errs); return errs ? 1 : 0; diff --git a/t_stub.c b/t_stub.c index 1518c7932..c5a39bfab 100644 --- a/t_stub.c +++ b/t_stub.c @@ -26,6 +26,7 @@ int inplace = 0; int am_daemon = 0; int am_chrooted = 0; int insecure_links = 0; +int copy_dirlinks = 0; int modify_window = 0; int preallocate_files = 0; int sparse_files = 0; @@ -45,6 +46,7 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained; * per-component fallback of secure_relative_open() * hits at its first my_strdup() call. */ char *partial_dir; +char *files_from; char *module_dir; int module_dirfd = -1; char *confine_root; diff --git a/testsuite/relative-source-ancestor_test.py b/testsuite/relative-source-ancestor_test.py new file mode 100644 index 000000000..38a697cb1 --- /dev/null +++ b/testsuite/relative-source-ancestor_test.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Explicit source directory symlinks remain transfer roots.""" + +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') +absolute_link = SCRATCHDIR / 'home' +relative_link = SCRATCHDIR / 'relative-home' +os.symlink(str(real), absolute_link) +os.symlink(str(real), relative_link) + +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, source_arg, str(dest) + '/'), + cwd=cwd, capture_output=True, text=True, + ) + if proc.returncode: + 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') + +file_sources = ( + ('absolute-file', str(absolute_link / 'My_Documents' / 'marker'), None), + ('relative-file', 'relative-home/My_Documents/marker', SCRATCHDIR), +) +for name, source_arg, cwd in file_sources: + dest = SCRATCHDIR / f'dest-{name}' + dest.mkdir() + proc = subprocess.run( + rsync_argv('-R', source_arg, str(dest) + '/'), + cwd=cwd, capture_output=True, text=True, + ) + if proc.returncode: + test_fail(f'{name} transfer failed: {proc.stdout}{proc.stderr}') + if cwd is None: + expected = dest / str(absolute_link.relative_to('/')) / 'My_Documents' / 'marker' + else: + expected = dest / source_arg + if not expected.is_file() or expected.read_text() != 'source contents\n': + test_fail(f'{name} layout or contents changed') + +files_from = SCRATCHDIR / 'files-from' +files_from.write_text('relative-home/My_Documents/marker\n') +dest = SCRATCHDIR / 'dest-files-from' +dest.mkdir() +proc = subprocess.run( + rsync_argv('-r', f'--files-from={files_from}', + str(SCRATCHDIR) + '/', str(dest) + '/'), + capture_output=True, text=True, +) +expected = dest / 'relative-home' / 'My_Documents' / 'marker' +if proc.returncode or not expected.is_file() or expected.read_text() != 'source contents\n': + test_fail(f'files-from trusted ancestor transfer failed: ' + f'{proc.stdout}{proc.stderr}') + +files_from.write_text('relative-home/My_Documents/\n') +dest = SCRATCHDIR / 'dest-files-from-dir' +dest.mkdir() +proc = subprocess.run( + rsync_argv('-r', f'--files-from={files_from}', + str(SCRATCHDIR) + '/', str(dest) + '/'), + capture_output=True, text=True, +) +expected = dest / 'relative-home' / 'My_Documents' / 'marker' +if proc.returncode or not expected.is_file() or expected.read_text() != 'source contents\n': + test_fail(f'files-from trusted directory transfer failed: ' + f'{proc.stdout}{proc.stderr}') + +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 / 'untrusted-file-dest' + dest.mkdir() + source_arg = str(untrusted_link / 'My_Documents' / 'marker') + proc = subprocess.run( + rsync_argv('-R', source_arg, str(dest) + '/'), + capture_output=True, text=True, + ) + expected = dest / str(untrusted_link.relative_to('/')) / 'My_Documents' / 'marker' + if proc.returncode or not expected.is_file(): + test_fail(f'explicit untrusted-owned file path failed: ' + f'{proc.stdout}{proc.stderr}') + + files_from.write_text('untrusted-home/My_Documents/marker\n') + dest = SCRATCHDIR / 'untrusted-files-from-dest' + dest.mkdir() + proc = subprocess.run( + rsync_argv('-r', f'--files-from={files_from}', + str(SCRATCHDIR) + '/', str(dest) + '/'), + capture_output=True, text=True, + ) + escaped = dest / 'untrusted-home' / 'My_Documents' / 'marker' + if proc.returncode == 0 or escaped.exists(): + test_fail('files-from followed an untrusted-owned ancestor symlink') + + files_from.write_text('untrusted-home/My_Documents/\n') + dest = SCRATCHDIR / 'untrusted-files-from-dir-dest' + dest.mkdir() + proc = subprocess.run( + rsync_argv('-r', f'--files-from={files_from}', + str(SCRATCHDIR) + '/', str(dest) + '/'), + capture_output=True, text=True, + ) + escaped = dest / 'untrusted-home' / 'My_Documents' / 'marker' + if proc.returncode == 0 or escaped.exists(): + test_fail('files-from enumerated an untrusted-owned ancestor symlink') + + dest = SCRATCHDIR / 'insecure-files-from-dest' + dest.mkdir() + proc = subprocess.run( + rsync_argv('-r', '--insecure-links', f'--files-from={files_from}', + str(SCRATCHDIR) + '/', str(dest) + '/'), + capture_output=True, text=True, + ) + expected = dest / 'untrusted-home' / 'My_Documents' / 'marker' + if proc.returncode or not expected.is_file(): + test_fail(f'files-from insecure-links opt-out failed: ' + f'{proc.stdout}{proc.stderr}') + +dest = SCRATCHDIR / 'remove-dest' +dest.mkdir() +proc = subprocess.run( + rsync_argv('-r', '--remove-source-files', str(absolute_link / 'My_Documents') + '/', str(dest) + '/'), + capture_output=True, text=True, +) +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')