diff --git a/NEWS.md b/NEWS.md index da09538b4..8980de944 100644 --- a/NEWS.md +++ b/NEWS.md @@ -119,6 +119,10 @@ modtime. ### BUG FIXES: +- Added support for a per-directory relative form of `--compare-dest`, + `--copy-dest`, and `--link-dest`: prefix a relative DIR with `: ` to make it + resolve from each destination file's containing directory. + - Fixed a regression introduced by the 3.4.0 secure_relative_open() CVE fix where legitimate directory symlinks on the receiver side (e.g. when using `-K` / `--copy-dirlinks`) caused "failed diff --git a/generator.c b/generator.c index 8642236e7..700fe7f23 100644 --- a/generator.c +++ b/generator.c @@ -960,7 +960,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, int j = 0; do { - pathjoin(cmpbuf, MAXPATHLEN, basis_dir[j], fname); + pathjoin_altdest(cmpbuf, MAXPATHLEN, j, fname); if (link_stat(cmpbuf, &sxp->st, 0) < 0 || !S_ISREG(sxp->st.st_mode)) continue; if (match_level == 0) { @@ -986,7 +986,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, if (j != best_match) { j = best_match; - pathjoin(cmpbuf, MAXPATHLEN, basis_dir[j], fname); + pathjoin_altdest(cmpbuf, MAXPATHLEN, j, fname); if (link_stat(cmpbuf, &sxp->st, 0) < 0) goto got_nothing_for_ya; } @@ -1081,7 +1081,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, } do { - pathjoin(cmpbuf, MAXPATHLEN, basis_dir[j], fname); + pathjoin_altdest(cmpbuf, MAXPATHLEN, j, fname); if (link_stat(cmpbuf, &sxp->st, 0) < 0) continue; if (ftype != get_file_type(sxp->st.st_mode)) @@ -1108,7 +1108,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, if (j != best_match) { j = best_match; - pathjoin(cmpbuf, MAXPATHLEN, basis_dir[j], fname); + pathjoin_altdest(cmpbuf, MAXPATHLEN, j, fname); if (link_stat(cmpbuf, &sxp->st, 0) < 0) return -1; } @@ -1773,7 +1773,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, int i; strlcpy(fnamecmpbuf, dn, sizeof fnamecmpbuf); for (i = 0; i < fuzzy_basis; i++) { - if (i && pathjoin(fnamecmpbuf, MAXPATHLEN, basis_dir[i-1], dn) >= MAXPATHLEN) + if (i && pathjoin_altdest_dir(fnamecmpbuf, MAXPATHLEN, i-1, dn) >= MAXPATHLEN) continue; fuzzy_dirlist[i] = get_dirlist(fnamecmpbuf, -1, GDL_IGNORE_FILTER_RULES | GDL_PERHAPS_DIR); if (fuzzy_dirlist[i] && fuzzy_dirlist[i]->used == 0) { diff --git a/hlink.c b/hlink.c index eb36730fd..86eddf857 100644 --- a/hlink.c +++ b/hlink.c @@ -356,8 +356,8 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, } if (alt_dest >= 0 && dry_run) { - pathjoin(namebuf, MAXPATHLEN, basis_dir[alt_dest], - f_name(prev_file, NULL)); + pathjoin_altdest(namebuf, MAXPATHLEN, alt_dest, + f_name(prev_file, NULL)); prev_name = namebuf; realname = f_name(prev_file, altbuf); } else { @@ -389,7 +389,7 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, int j = 0; init_stat_x(&alt_sx); do { - pathjoin(cmpbuf, MAXPATHLEN, basis_dir[j], fname); + pathjoin_altdest(cmpbuf, MAXPATHLEN, j, fname); if (link_stat(cmpbuf, &alt_sx.st, 0) < 0) continue; if (alt_dest_type == LINK_DEST) { @@ -496,8 +496,8 @@ void finish_hard_link(struct file_struct *file, const char *fname, int fin_ndx, file->flags |= FLAG_HLINK_FIRST | FLAG_HLINK_DONE; F_HL_PREV(file) = alt_dest; if (alt_dest >= 0 && dry_run) { - pathjoin(alt_name, MAXPATHLEN, basis_dir[alt_dest], - f_name(file, NULL)); + pathjoin_altdest(alt_name, MAXPATHLEN, alt_dest, + f_name(file, NULL)); our_name = alt_name; } else our_name = fname; diff --git a/main.c b/main.c index 9b52bbe6a..f29391606 100644 --- a/main.c +++ b/main.c @@ -87,6 +87,7 @@ extern BOOL want_progress_now; extern BOOL shutting_down; extern int backup_dir_len; extern int basis_dir_cnt; +extern int basis_dir_perdir[MAX_BASIS_DIRS+1]; extern int default_af_hint; extern int stdout_format_has_i; extern int trust_sender_filter; @@ -867,7 +868,7 @@ static void check_alt_basis_dirs(void) int bd_len = strlen(bdir); if (bd_len > 1 && bdir[bd_len-1] == '/') bdir[--bd_len] = '\0'; - if (dry_run > 1 && *bdir != '/') { + if (!basis_dir_perdir[j] && dry_run > 1 && *bdir != '/') { int len = curr_dir_len + 1 + bd_len + 1; char *new = new_array(char, len); if (slash && strncmp(bdir, "../", 3) == 0) { @@ -882,6 +883,8 @@ static void check_alt_basis_dirs(void) pathjoin(new, len, curr_dir, bdir); basis_dir[j] = bdir = new; } + if (basis_dir_perdir[j] && *bdir != '/') + continue; if (do_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)) diff --git a/options.c b/options.c index 8568af2b2..ed19efc20 100644 --- a/options.c +++ b/options.c @@ -183,6 +183,7 @@ char *backup_suffix = NULL; char *tmpdir = NULL; char *partial_dir = NULL; char *basis_dir[MAX_BASIS_DIRS+1]; +int basis_dir_perdir[MAX_BASIS_DIRS+1]; char *config_file = NULL; char *shell_cmd = NULL; char *logfile_name = NULL; @@ -1737,6 +1738,7 @@ int parse_arguments(int *argc_p, const char ***argv_p) want_dest_type = COMPARE_DEST; set_dest_dir: + arg = poptGetOptArg(pc); if (alt_dest_type && alt_dest_type != want_dest_type) { snprintf(err_buf, sizeof err_buf, "ERROR: the %s option conflicts with the %s option\n", @@ -1751,9 +1753,13 @@ int parse_arguments(int *argc_p, const char ***argv_p) MAX_BASIS_DIRS, alt_dest_opt(0)); goto cleanup; } + if (arg[0] == ':' && arg[1] == ' ') { + basis_dir_perdir[basis_dir_cnt] = 1; + arg += 2; + } /* We defer sanitizing this arg until we know what * our destination directory is going to be. */ - basis_dir[basis_dir_cnt++] = (char *)poptGetOptArg(pc); + basis_dir[basis_dir_cnt++] = (char *)arg; break; case OPT_CHMOD: @@ -2935,8 +2941,15 @@ void server_options(char **args, int *argc_p) * option, so don't send it if client is the sender. */ for (i = 0; i < basis_dir_cnt; i++) { + char *basis_arg = basis_dir[i]; args[ac++] = alt_dest_opt(0); - args[ac++] = safe_arg("", basis_dir[i]); + if (basis_dir_perdir[i]) { + int len = strlen(basis_arg) + 3; + char *new = new_array(char, len); + snprintf(new, len, ": %s", basis_arg); + basis_arg = new; + } + args[ac++] = safe_arg("", basis_arg); } } } diff --git a/receiver.c b/receiver.c index 2b263cea0..23165f120 100644 --- a/receiver.c +++ b/receiver.c @@ -43,6 +43,7 @@ extern int write_devices; extern int preserve_xattrs; extern int do_fsync; extern int basis_dir_cnt; +extern int basis_dir_perdir[MAX_BASIS_DIRS+1]; extern int make_backups; extern int cleanup_got_literal; extern int remove_source_files; @@ -844,7 +845,8 @@ int recv_files(int f_in, int f_out, char *local_name) if (fnamecmp_type > FNAMECMP_FUZZY && fnamecmp_type-FNAMECMP_FUZZY <= basis_dir_cnt) { fnamecmp_type -= FNAMECMP_FUZZY + 1; if (file->dirname) { - pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, basis_dir[fnamecmp_type], file->dirname); + pathjoin_altdest_dir(fnamecmpbuf, sizeof fnamecmpbuf, + fnamecmp_type, file->dirname); basedir = fnamecmpbuf; } else { basedir = basis_dir[fnamecmp_type]; @@ -856,8 +858,13 @@ int recv_files(int f_in, int f_out, char *local_name) fnamecmp_type); exit_cleanup(RERR_PROTOCOL); } else { - basedir = basis_dir[fnamecmp_type]; - fnamecmp = fname; + if (basis_dir_perdir[fnamecmp_type] && *basis_dir[fnamecmp_type] != '/') { + pathjoin_altdest(fnamecmpbuf, sizeof fnamecmpbuf, fnamecmp_type, fname); + fnamecmp = fnamecmpbuf; + } else { + basedir = basis_dir[fnamecmp_type]; + fnamecmp = fname; + } } break; } @@ -893,8 +900,14 @@ int recv_files(int f_in, int f_out, char *local_name) if (fd1 == -1 && basis_dir[0]) { /* pre-29 allowed only one alternate basis */ - basedir = basis_dir[0]; - fnamecmp = fname; + if (basis_dir_perdir[0] && *basis_dir[0] != '/') { + pathjoin_altdest(fnamecmpbuf, sizeof fnamecmpbuf, 0, fname); + basedir = NULL; + fnamecmp = fnamecmpbuf; + } else { + basedir = basis_dir[0]; + fnamecmp = fname; + } fnamecmp_type = FNAMECMP_BASIS_DIR_LOW; fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); } diff --git a/rsync.1.md b/rsync.1.md index fdf0b2e95..d564dd6d6 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -2677,6 +2677,8 @@ expand it. transfer. If _DIR_ is a relative path, it is relative to the destination directory. + If _DIR_ begins with `: `, the rest of the path is instead relative to + each destination file's containing directory. See also [`--copy-dest`](#opt) and [`--link-dest`](#opt). NOTE: beginning with version 3.1.0, rsync will remove a file from a @@ -2698,6 +2700,8 @@ expand it. try to speed up the transfer. If _DIR_ is a relative path, it is relative to the destination directory. + If _DIR_ begins with `: `, the rest of the path is instead relative to + each destination file's containing directory. See also [`--compare-dest`](#opt) and [`--link-dest`](#opt). 0. `--link-dest=DIR` @@ -2735,6 +2739,8 @@ expand it. the file is updated. If _DIR_ is a relative path, it is relative to the destination directory. + If _DIR_ begins with `: `, the rest of the path is instead relative to + each destination file's containing directory. See also [`--compare-dest`](#opt) and [`--copy-dest`](#opt). Note that rsync versions prior to 2.6.1 had a bug that could prevent diff --git a/t_stub.c b/t_stub.c index 2b99e74d3..a7d8c0556 100644 --- a/t_stub.c +++ b/t_stub.c @@ -44,6 +44,8 @@ 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 *basis_dir[MAX_BASIS_DIRS+1]; +int basis_dir_perdir[MAX_BASIS_DIRS+1]; char *module_dir; /* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in * syscall.c, which every helper links -- no stub needed here. */ diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 6f6e37cf3..7fda88828 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -82,7 +82,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test | --inplace | inplace*new*, alt-dest | Y | — | ✓ inode preserved | | --append / --append-verify | append*new* | Y | — | ✓ verify split is proto 30+ | | -b, --backup / --backup-dir / --suffix | backup, backup-deep*new* | Y | Y | ✓ | -| --compare-dest / --copy-dest / --link-dest | alt-dest, alt-dest-deep*new* | Y | Y | ✓ link=hardlink, copy=copy, compare=skip | +| --compare-dest / --copy-dest / --link-dest | alt-dest, alt-dest-deep*new*, alt-dest-per-dir*new* | Y | Y | ✓ link=hardlink, copy=copy, compare=skip, per-dir `: REL` | | -y, --fuzzy | fuzzy | `~` | — | `~` | | -u, --update | update*new* | Y | — | ✓ keeps newer dest, updates older | | -W, --whole-file | (used widely; --no-whole-file ubiquitous) | n/a | — | `~` | diff --git a/testsuite/alt-dest-per-dir_test.py b/testsuite/alt-dest-per-dir_test.py new file mode 100644 index 000000000..56d6493a5 --- /dev/null +++ b/testsuite/alt-dest-per-dir_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Per-directory relative --compare/copy/link-dest paths. + +An alt-dest arg prefixed with ": " is resolved from each destination file's +containing directory. The fixture mirrors a restructure where an old flat file +already exists in the destination and the source now places it one level deeper. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_exists, assert_hardlinked, assert_not_exists, assert_not_hardlinked, + assert_same, rmtree, run_rsync, test_fail, +) + +old_rel = os.path.join('show', 'episode.mkv') +new_rel = os.path.join('show', 'season1', 'episode.mkv') + + +def seed_trees(old_name='episode.mkv'): + rmtree(FROMDIR) + rmtree(TODIR) + (FROMDIR / 'show' / 'season1').mkdir(parents=True) + (TODIR / 'show').mkdir(parents=True) + data = b'episode payload\n' + (FROMDIR / new_rel).write_bytes(data) + (TODIR / 'show' / old_name).write_bytes(data) + os.utime(FROMDIR / new_rel, (1000000000, 1000000000)) + os.utime(TODIR / 'show' / old_name, (1000000000, 1000000000)) + + +def run_to(opt, *extra): + seed_trees() + run_rsync('-a', *extra, f'--{opt}=: ..', f'{FROMDIR}/', f'{TODIR}/') + + +run_to('link-dest') +assert_exists(TODIR / new_rel, label='link-dest per-dir result') +assert_hardlinked(TODIR / new_rel, TODIR / old_rel, label='link-dest : ..') + +run_to('copy-dest') +assert_exists(TODIR / new_rel, label='copy-dest per-dir result') +assert_same(TODIR / new_rel, FROMDIR / new_rel, label='copy-dest : ..') +assert_not_hardlinked(TODIR / new_rel, TODIR / old_rel, label='copy-dest : ..') + +run_to('compare-dest') +assert_not_exists(TODIR / new_rel, label='compare-dest : ..') + +seed_trees(old_name='episode-old.mkv') +proc = run_rsync('-a', '--debug=FUZZY1', '--fuzzy', '--fuzzy', + '--link-dest=: ..', f'{FROMDIR}/', f'{TODIR}/', + capture_output=True) +out = proc.stdout + proc.stderr +if 'fuzzy basis selected for show/season1/episode.mkv: show/season1/../episode-old.mkv' not in out: + test_fail(f"--fuzzy did not scan per-dir --link-dest=: .. basis:\n{out}") +assert_exists(TODIR / new_rel, label='fuzzy per-dir basis result') +assert_same(TODIR / new_rel, FROMDIR / new_rel, label='fuzzy : ..') +assert_not_hardlinked(TODIR / new_rel, TODIR / 'show' / 'episode-old.mkv', + label='fuzzy transfers delta') + +print("alt-dest-per-dir: per-directory --compare/copy/link-dest basis paths verified") diff --git a/util1.c b/util1.c index d9d9f4bcf..eb38bc57c 100644 --- a/util1.c +++ b/util1.c @@ -37,6 +37,8 @@ extern int preallocate_files; extern char *module_dir; extern unsigned int module_dirlen; extern char *partial_dir; +extern char *basis_dir[MAX_BASIS_DIRS+1]; +extern int basis_dir_perdir[MAX_BASIS_DIRS+1]; extern filter_rule_list daemon_filter_list; int sanitize_paths = 0; @@ -905,6 +907,49 @@ size_t pathjoin(char *dest, size_t destsize, const char *p1, const char *p2) return len; } +/* Join an alternate-basis dir and a destination filename. A per-directory + * basis dir is relative to the destination file's containing directory. */ +size_t pathjoin_altdest(char *dest, size_t destsize, int basis_ndx, const char *fname) +{ + const char *bdir = basis_dir[basis_ndx]; + const char *slash; + size_t bdir_len; + int len; + + if (!basis_dir_perdir[basis_ndx] || *bdir == '/') + return pathjoin(dest, destsize, bdir, fname); + + slash = strrchr(fname, '/'); + if (!slash) { + if (!*bdir) + return strlcpy(dest, fname, destsize); + return pathjoin(dest, destsize, bdir, fname); + } + + if (!*bdir) + len = snprintf(dest, destsize, "%.*s/%s", (int)(slash - fname), fname, slash + 1); + else { + bdir_len = strlen(bdir); + len = snprintf(dest, destsize, "%.*s/%s%s%s", + (int)(slash - fname), fname, bdir, + bdir[bdir_len-1] == '/' ? "" : "/", slash + 1); + } + + return len < 0 ? destsize : (size_t)len; +} + +/* Join an alternate-basis dir and a destination directory. */ +size_t pathjoin_altdest_dir(char *dest, size_t destsize, int basis_ndx, const char *dir) +{ + const char *bdir = basis_dir[basis_ndx]; + + if (!basis_dir_perdir[basis_ndx] || *bdir == '/') + return pathjoin(dest, destsize, bdir, dir); + if (!*bdir) + return strlcpy(dest, dir, destsize); + return pathjoin(dest, destsize, dir, bdir); +} + /* Join any number of strings together, putting them in "dest". The return * value is the length of all the strings, regardless of whether the null- * terminated whole fits in destsize. Your list of string pointers must end