From ef8b17f438699fa1c84f4af2fe400bd884c97764 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 14 Nov 2026 12:47:14 +0100 Subject: [PATCH] Do not let rsync() write through symlinks in the destination With LITE_FOPT_RSYNC_LINKS, rsync() leaves symlinks in the destination. If the source later has a regular file or a directory by the same name, the next run follows the old link and writes to whatever it points at, which can be outside the destination tree. Remove such a link before copying. Signed-off-by: Joachim Wiberg --- doc/API.md | 4 +++ src/rsync.c | 54 +++++++++++++++++++++++++++++++++-------- test/copyfile-symlink.c | 34 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/doc/API.md b/doc/API.md index 2808235..7cc82d9 100644 --- a/doc/API.md +++ b/doc/API.md @@ -250,6 +250,10 @@ Generic Functions gets a copy of what they point to. With `LITE_FOPT_RSYNC_LINKS` a symlink to a file, or a dangling symlink, is recreated in the destination instead. Symlinks to directories are always followed. + + With `LITE_FOPT_RSYNC_LINKS` a symlink in the destination is never + followed. If one sits where a file or directory from the source + goes, it is removed first. In releases prior to v2.0 the argument controlling pruning was called `delete`, it is now called `opt`. The APIs are 100% compatible if the diff --git a/src/rsync.c b/src/rsync.c index 9869e67..9aea73e 100644 --- a/src/rsync.c +++ b/src/rsync.c @@ -36,7 +36,7 @@ #include "lite.h" static int copy(char *src, char *dst, int opt); -static int mdir(char *buf, size_t buf_len, char *dir, char *name, struct stat *st); +static int mdir(char *buf, size_t buf_len, char *dir, char *name, struct stat *st, int opt); static int prune(char *dst, char **new_files, int new_num); static int set_mtime(char *fn, struct stat *st); @@ -69,6 +69,10 @@ static int set_mtime(char *fn, struct stat *st); * text, replacing any file or symlink already there. Symlinks to * directories in @p src are always followed. * + * With ::LITE_FOPT_RSYNC_LINKS a symlink in @p dst is never followed. + * If one sits where a file or directory from @p src goes, it is removed + * first. + * * The filter callback, @p filter, if provided, is used to determine * what files to include from the source directory when backing up. If * a file is to be skipped the callback should simply return zero. @@ -113,7 +117,7 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) if (stat(src, &st)) return 1; - if (mdir(dest, sizeof(dest), dst, ptr, &st)) + if (mdir(dest, sizeof(dest), dst, ptr, &st, opt)) return 1; dst = dest; do_mtim = keep_mtim; @@ -133,7 +137,7 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) continue; } - if (mdir(dst2, sizeof(dst2), dst, files[i], &sb)) { + if (mdir(dst2, sizeof(dst2), dst, files[i], &sb, opt)) { result++; continue; } @@ -166,20 +170,30 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) return result; } +/* Leaves errno alone, a missing @path is not an error to rsync() */ +static int islink(const char *path) +{ + int rc, saved_errno = errno; + struct stat st; + + rc = !lstat(path, &st) && S_ISLNK(st.st_mode); + errno = saved_errno; + + return rc; +} + static int copy(char *src, char *dst, int opt) { char *dest = NULL, *temp = NULL, *file = NULL; int result = 1, saved_errno, made_temp = 0; int copt = opt & LITE_FOPT_KEEP_MTIME; - if (opt & LITE_FOPT_RSYNC_LINKS) - copt |= LITE_FOPT_COPYFILE_SYM; - - copyfile(src, dst, 0, copt); - if (errno != EEXIST) + if (!(opt & LITE_FOPT_RSYNC_LINKS)) { + copyfile(src, dst, 0, copt); return errno ? 1 : 0; + } + copt |= LITE_FOPT_COPYFILE_SYM; - /* Replace an existing destination only after the new link is ready. */ if (fisdir(dst)) { char *base = strrchr(src, '/'); @@ -190,6 +204,21 @@ static int copy(char *src, char *dst, int opt) if (!dest) return 1; } + + /* + * A link in the destination, e.g. from a previous run, may point + * anywhere. Remove it so copyfile() cannot write through it. + */ + if (islink(dest) && !islink(src)) + unlink(dest); + + copyfile(src, dst, 0, copt); + if (errno != EEXIST) { + result = errno ? 1 : 0; + goto exit; + } + + /* Replace an existing destination only after the new link is ready. */ if (asprintf(&temp, "%s/.rsync-XXXXXX", dirname(strdupa(dest))) < 0) goto exit; if (!mkdtemp(temp)) @@ -218,9 +247,14 @@ exit: } /* Creates dir/name @mode ... skipping / if dir already ends so. */ -static int mdir(char *buf, size_t buf_len, char *dir, char *name, struct stat *st) +static int mdir(char *buf, size_t buf_len, char *dir, char *name, struct stat *st, int opt) { snprintf(buf, buf_len, "%s%s%s", dir, fisslashdir(dir) ? "" : "/", name); + + /* Same as in copy(), do not descend into a stale link. */ + if ((opt & LITE_FOPT_RSYNC_LINKS) && islink(buf)) + unlink(buf); + if (mkdir(buf, st->st_mode)) { if (EEXIST != errno) return 1; diff --git a/test/copyfile-symlink.c b/test/copyfile-symlink.c index 58de5ee..5310fde 100644 --- a/test/copyfile-symlink.c +++ b/test/copyfile-symlink.c @@ -15,6 +15,39 @@ static void check_link(const char *path, const char *target) fail_unless(memcmp(buf, target, len) == 0); } +/* A link left in the destination by an earlier run must never be written through. */ +static void rsync_stale_links(void) +{ + struct stat st; + FILE *fp; + + fail_unless(mkdir("src", 0700) == 0 && mkdir("dst", 0700) == 0); + fail_unless(touch("victim") == 0); + fail_unless(symlink("../victim", "src/a") == 0); + fail_unless(symlink("../victims", "src/d") == 0); + fail_unless(rsync("src/", "dst/", LITE_FOPT_RSYNC_LINKS, NULL) == 0); + check_link("dst/a", "../victim"); + check_link("dst/d", "../victims"); + + /* The source links become a file and a directory, and dst/d stops dangling. */ + fail_unless(unlink("src/a") == 0 && unlink("src/d") == 0); + fp = fopen("src/a", "w"); + fail_unless(fp && fputs("data", fp) >= 0 && fclose(fp) == 0); + fail_unless(mkdir("src/d", 0700) == 0 && touch("src/d/f") == 0); + fail_unless(mkdir("victims", 0700) == 0); + fail_unless(rsync("src/", "dst/", LITE_FOPT_RSYNC_LINKS, NULL) == 0); + + fail_unless(lstat("dst/a", &st) == 0 && S_ISREG(st.st_mode) && st.st_size == 4); + fail_unless(stat("victim", &st) == 0 && st.st_size == 0); + fail_unless(lstat("dst/d", &st) == 0 && S_ISDIR(st.st_mode)); + fail_unless(fexist("dst/d/f") && !fexist("victims/f")); + + fail_unless(unlink("src/a") == 0 && unlink("src/d/f") == 0 && rmdir("src/d") == 0); + fail_unless(unlink("dst/a") == 0 && unlink("dst/d/f") == 0 && rmdir("dst/d") == 0); + fail_unless(rmdir("src") == 0 && rmdir("dst") == 0); + fail_unless(unlink("victim") == 0 && rmdir("victims") == 0); +} + int main(void) { char tmp[] = "/tmp/libite-copylink-XXXXXX"; @@ -106,6 +139,7 @@ int main(void) fail_unless(unlink("protected/link") == 0 && rmdir("protected") == 0); } fail_unless(unlink("copied") == 0 && unlink("link") == 0); + rsync_stale_links(); errno = 0; fail_unless(copyfile("dir", "copied", 0, LITE_FOPT_COPYFILE_SYM) == 0 && errno == EISDIR);