From 666a8ef7cdfcd941054b54cc4d225da382092c6d Mon Sep 17 00:00:00 2001 From: omrzk <193513049+omrzk@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:20:18 -0600 Subject: [PATCH 1/4] Honor the symbolic link option when copying files Inspect source links without following them when COPYFILE_SYM is requested, before rejecting directories. This makes the documented option recreate link text instead of copying the referent, including dangling and directory links. Preserve requested link timestamps and report link-creation errors without overwriting the destination. Cover direct calls, destination directories, normal dereferencing and the existing rsync file caller. --- src/copyfile.c | 26 +++++---- src/rsync.c | 45 ++++++++++++++-- test/.gitignore | 1 + test/Makefile.am | 2 + test/copyfile-symlink.c | 114 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 test/copyfile-symlink.c diff --git a/src/copyfile.c b/src/copyfile.c index 2936daa..593f1a9 100644 --- a/src/copyfile.c +++ b/src/copyfile.c @@ -157,7 +157,13 @@ ssize_t copyfile(const char *src, const char *dst, int len, int opt) if (!buffer) return 0; - if (fisdir(src)) { + if (sym ? lstat(src, &st) : stat(src, &st)) { + saved_errno = errno; + size = -1; + goto exit; + } + + if (S_ISDIR(st.st_mode)) { saved_errno = EISDIR; /* Error: source is a directory */ goto exit; } @@ -165,13 +171,7 @@ ssize_t copyfile(const char *src, const char *dst, int len, int opt) /* Check if target is a directory, then append src filename. */ isdir = adjust_target(src, &dest); - /* Check if the source file is a symlink ... */ - if (stat(src, &st)) { - size = -1; - goto exit; - } - - /* ... only try readlink() if symlink and @sym is set. */ + /* Only try readlink() if symlink and @sym is set. */ if (sym && (st.st_mode & S_IFMT) == S_IFLNK) { size = readlink(src, buffer, BUFSIZ); if (size > 0) { @@ -181,8 +181,16 @@ ssize_t copyfile(const char *src, const char *dst, int len, int opt) } else { buffer[size] = 0; size = !symlink(buffer, dest); + if (!size) + saved_errno = errno; + else if (keep_mtim) { + struct timespec tv[2] = { st.st_atim, st.st_mtim }; + + utimensat(AT_FDCWD, dest, tv, AT_SYMLINK_NOFOLLOW); + } } - } + } else + saved_errno = errno; /* Don't fall back to copy, that is a potentially * dangerous race condition, see CWE-367. */ diff --git a/src/rsync.c b/src/rsync.c index 695fac3..47fd5fc 100644 --- a/src/rsync.c +++ b/src/rsync.c @@ -24,6 +24,7 @@ #include #include /* AT_* macros */ +#include /* dirname() */ #include /* NULL, free() */ #include /* strlen() */ #include /* rindex() */ @@ -159,15 +160,49 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) static int copy(char *src, char *dst, int opt) { + char *dest = NULL, *temp = NULL, *file = NULL; + int result = 1, saved_errno, made_temp = 0; + copyfile(src, dst, 0, opt | LITE_FOPT_COPYFILE_SYM); - if (errno) { - if (errno != EEXIST) + if (errno != EEXIST) + return errno ? 1 : 0; + + /* Replace an existing destination only after the new link is ready. */ + if (fisdir(dst)) { + char *base = strrchr(src, '/'); + + if (asprintf(&dest, "%s%s%s", dst, fisslashdir(dst) ? "" : "/", base ? base + 1 : src) < 0) + return 1; + } else { + dest = strdup(dst); + if (!dest) return 1; - - errno = 0; } + if (asprintf(&temp, "%s/.rsync-XXXXXX", dirname(strdupa(dest))) < 0) + goto exit; + if (!mkdtemp(temp)) + goto exit; + made_temp = 1; + if (asprintf(&file, "%s/link", temp) < 0) + goto exit; - return 0; + copyfile(src, file, 0, opt | LITE_FOPT_COPYFILE_SYM); + if (errno) + goto exit; + result = rename(file, dest) != 0; + +exit: + saved_errno = errno; + if (file) + unlink(file); + if (made_temp) + rmdir(temp); + free(file); + free(temp); + free(dest); + errno = saved_errno; + + return result; } /* Creates dir/name @mode ... skipping / if dir already ends so. */ diff --git a/test/.gitignore b/test/.gitignore index 7128afe..6701e84 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -5,6 +5,7 @@ /chomp /copyfile /fcopyfile +/copyfile-symlink /dir /fexist /fisdir diff --git a/test/Makefile.am b/test/Makefile.am index ef46bcc..36e1d2c 100644 --- a/test/Makefile.am +++ b/test/Makefile.am @@ -20,6 +20,7 @@ TESTS = TESTS += chomp TESTS += copyfile TESTS += fcopyfile +TESTS += copyfile-symlink TESTS += dir TESTS += fexist TESTS += fisdir @@ -41,3 +42,4 @@ TESTS += which TESTS += yorn check_PROGRAMS = $(TESTS) +copyfile_symlink_SOURCES = copyfile-symlink.c diff --git a/test/copyfile-symlink.c b/test/copyfile-symlink.c new file mode 100644 index 0000000..d4c55c6 --- /dev/null +++ b/test/copyfile-symlink.c @@ -0,0 +1,114 @@ +#include +#include +#include +#include "check.h" + +static void check_link(const char *path, const char *target) +{ + char buf[256]; + struct stat st; + ssize_t len; + + fail_unless(lstat(path, &st) == 0 && S_ISLNK(st.st_mode)); + len = readlink(path, buf, sizeof(buf)); + fail_unless(len == (ssize_t)strlen(target)); + fail_unless(memcmp(buf, target, len) == 0); +} + +int main(void) +{ + char tmp[] = "/tmp/libite-copylink-XXXXXX"; + const char payload[] = "target contents"; + char buf[sizeof(payload)]; + const char *targets[] = { "file", "dir", "missing", ".", "link" }; + struct timespec times[2] = { { 1234567800, 0 }, { 1234567801, 0 } }; + struct stat src_st, dst_st; + char cwd[4096]; + size_t i; + FILE *fp; + + fail_unless(getcwd(cwd, sizeof(cwd)) && mkdtemp(tmp)); + fail_unless(chdir(tmp) == 0); + fail_unless(mkdir("dir", 0700) == 0); + fail_unless(mkdir("out", 0700) == 0); + fp = fopen("file", "w"); + fail_unless(fp && fwrite(payload, 1, sizeof(payload), fp) == sizeof(payload)); + fail_unless(fclose(fp) == 0); + + for (i = 0; i < NELEMS(targets); i++) { + fail_unless(symlink(targets[i], "link") == 0); + fail_unless(copyfile("link", "copied", 0, LITE_FOPT_COPYFILE_SYM) == 1); + check_link("copied", targets[i]); + fail_unless(copyfile("link", "out", 0, LITE_FOPT_COPYFILE_SYM) == 1); + check_link("out/link", targets[i]); + /* Existing targets must survive a failed symlink creation. */ + errno = 0; + fail_unless(copyfile("link", "file", 0, LITE_FOPT_COPYFILE_SYM) == 0); + fail_unless(errno == EEXIST); + check_link("copied", targets[i]); + fail_unless(unlink("copied") == 0 && unlink("out/link") == 0); + fail_unless(unlink("link") == 0); + } + + fail_unless(symlink("file", "link") == 0); + /* Without SYM, continue to copy target data and preserve regular-file metadata. */ + fail_unless(copyfile("link", "copied", 0, LITE_FOPT_KEEP_MTIME) == sizeof(payload)); + fail_unless(lstat("copied", &dst_st) == 0 && S_ISREG(dst_st.st_mode)); + fail_unless(stat("file", &src_st) == 0 && src_st.st_mtime == dst_st.st_mtime); + fp = fopen("copied", "r"); + fail_unless(fp && fread(buf, 1, sizeof(buf), fp) == sizeof(payload)); + fail_unless(memcmp(buf, payload, sizeof(payload)) == 0 && fclose(fp) == 0); + fail_unless(unlink("copied") == 0); + + fail_unless(utimensat(AT_FDCWD, "link", times, AT_SYMLINK_NOFOLLOW) == 0); + fail_unless(copyfile("link", "copied", 0, LITE_FOPT_COPYFILE_SYM | LITE_FOPT_KEEP_MTIME) == 1); + check_link("copied", "file"); + fail_unless(lstat("copied", &dst_st) == 0 && dst_st.st_mtime == times[1].tv_sec); + fail_unless(unlink("copied") == 0); + /* Exercise the existing rsync file caller, which requests SYM. */ + fail_unless(rsync("link", "copied", 0, NULL) == 0); + check_link("copied", "file"); + /* Reuse the destination after changing the source link. */ + fail_unless(unlink("link") == 0 && symlink("file", "link") == 0); + fail_unless(rsync("link", "out", 0, NULL) == 0); + check_link("out/link", "file"); + fail_unless(unlink("link") == 0 && symlink("copied", "link") == 0); + fail_unless(rsync("link", "out", 0, NULL) == 0); + check_link("out/link", "copied"); + /* A same-path copy must not unlink its source. */ + fail_unless(rsync("link", "link", 0, NULL) == 0); + check_link("link", "copied"); + fail_unless(unlink("out/link") == 0); + /* Replacing a regular destination must also retain the source link. */ + fail_unless(touch("out/link") == 0); + fail_unless(rsync("link", "out", 0, NULL) == 0); + check_link("out/link", "copied"); + fail_unless(unlink("out/link") == 0); + /* A failed replacement must keep both the destination and source. */ + fail_unless(mkdir("out/link", 0700) == 0); + fail_unless(touch("out/link/keep") == 0); + fail_unless(rsync("link", "out", 0, NULL) != 0); + fail_unless(fexist("out/link/keep")); + check_link("link", "copied"); + fail_unless(unlink("out/link/keep") == 0 && rmdir("out/link") == 0); + if (getuid() != 0) { + fail_unless(mkdir("protected", 0700) == 0); + fail_unless(symlink("original", "protected/link") == 0); + fail_unless(chmod("protected", 0500) == 0); + fail_unless(rsync("link", "protected", 0, NULL) != 0); + check_link("protected/link", "original"); + check_link("link", "copied"); + fail_unless(chmod("protected", 0700) == 0); + fail_unless(unlink("protected/link") == 0 && rmdir("protected") == 0); + } + fail_unless(unlink("copied") == 0 && unlink("link") == 0); + + errno = 0; + fail_unless(copyfile("dir", "copied", 0, LITE_FOPT_COPYFILE_SYM) == 0 && errno == EISDIR); + errno = 0; + fail_unless(copyfile("missing", "copied", 0, LITE_FOPT_COPYFILE_SYM) == -1 && errno == ENOENT); + + fail_unless(unlink("file") == 0 && rmdir("dir") == 0 && rmdir("out") == 0); + fail_unless(chdir(cwd) == 0 && rmdir(tmp) == 0); + return 0; +} From 4609c53ddd3d3be4dce955ee10fca22f7f5b9889 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 14 Nov 2026 12:46:48 +0100 Subject: [PATCH 2/4] Make rsync() recreate symlinks only on request Fixing LITE_FOPT_COPYFILE_SYM in copyfile() also changed rsync(), which has always passed that flag. Every caller would start getting links in the destination where they used to get a copy of the file. Keep following links by default and add LITE_FOPT_RSYNC_LINKS for callers that want them recreated. Signed-off-by: Joachim Wiberg --- ChangeLog.md | 7 +++++++ doc/API.md | 6 ++++++ src/lite.h | 1 + src/rsync.c | 22 +++++++++++++++++----- test/copyfile-symlink.c | 18 +++++++++++------- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index f83a311..8edc447 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -7,7 +7,14 @@ All notable changes to the project are documented in this file. [UNRELEASED][] -------------- +### Changes +- Add `LITE_FOPT_RSYNC_LINKS` option to `rsync()`, recreates symlinks to + files in the destination instead of copying what they point to + ### Fixes +- Fix #23: `copyfile()` ignored `LITE_FOPT_COPYFILE_SYM` and always + copied what a symlink points to. It now recreates the symlink, also + when it is dangling or points to a directory - Fix `copyfile()` not preserving the source file mode when the process umask is more restrictive - Fix `mkpath()`, and `fmkpath()`, not applying the given mode when the diff --git a/doc/API.md b/doc/API.md index e28102a..2808235 100644 --- a/doc/API.md +++ b/doc/API.md @@ -244,6 +244,12 @@ Generic Functions - `LITE_FOPT_RSYNC_DELETE (0x01)` - `LITE_FOPT_KEEP_MTIME (0x02)` + - `LITE_FOPT_RSYNC_LINKS (0x04)` + + Symlinks in the source tree are followed by default, the destination + 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. 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/lite.h b/src/lite.h index 2d03290..bf704e4 100644 --- a/src/lite.h +++ b/src/lite.h @@ -67,6 +67,7 @@ extern "C" #define LITE_FOPT_RSYNC_DELETE 0x01 /**< Prune files from destination that are not in source */ #define LITE_FOPT_COPYFILE_SYM 0x01 /**< Recreate symlink or follow to copy target */ #define LITE_FOPT_KEEP_MTIME 0x02 /**< Preserve modification time */ +#define LITE_FOPT_RSYNC_LINKS 0x04 /**< Recreate symlinks to files instead of copying their target */ typedef struct lfile lfile_t; /**< Opqaue context struct for lfile APIs */ typedef struct sdbuf sdbuf_t; /**< Opqaue context struct for telnet APIs */ diff --git a/src/rsync.c b/src/rsync.c index 47fd5fc..9869e67 100644 --- a/src/rsync.c +++ b/src/rsync.c @@ -45,7 +45,8 @@ static int set_mtime(char *fn, struct stat *st); * Synchronize contents and optionally remove non-existing backups * @param src Source directory * @param dst Destination directory - * @param opt An option mask of ::LITE_FOPT_RSYNC_DELETE, ::LITE_FOPT_KEEP_MTIME + * @param opt An option mask of ::LITE_FOPT_RSYNC_DELETE, ::LITE_FOPT_KEEP_MTIME, + * ::LITE_FOPT_RSYNC_LINKS * @param filter Optional filtering function for source directory. * * This is a miniature implementation of the famous rsync for local use @@ -60,6 +61,13 @@ static int set_mtime(char *fn, struct stat *st); * * %LITE_FOPT_RSYNC_DELETE: Prune files from @p dst that no longer exist in @p src. * %LITE_FOPT_KEEP_MTIME: Preserve modification time + * %LITE_FOPT_RSYNC_LINKS: Recreate symlinks to files in @p dst + * + * Symlinks in @p src are followed by default, so @p dst 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 @p dst with the same target + * text, replacing any file or symlink already there. Symlinks to + * directories in @p src are always followed. * * The filter callback, @p filter, if provided, is used to determine * what files to include from the source directory when backing up. If @@ -87,7 +95,7 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) if (!fexist(src)) return 1; - if (copy(src, dst, keep_mtim)) + if (copy(src, dst, opt)) return 1; return 0; @@ -137,7 +145,7 @@ int rsync(char *src, char *dst, int opt, int (*filter)(const char *file)) continue; /* Next file/dir in @src to copy... */ } - if (copy(source, dst, keep_mtim)) + if (copy(source, dst, opt)) result++; } @@ -162,8 +170,12 @@ 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; - copyfile(src, dst, 0, opt | LITE_FOPT_COPYFILE_SYM); + if (opt & LITE_FOPT_RSYNC_LINKS) + copt |= LITE_FOPT_COPYFILE_SYM; + + copyfile(src, dst, 0, copt); if (errno != EEXIST) return errno ? 1 : 0; @@ -186,7 +198,7 @@ static int copy(char *src, char *dst, int opt) if (asprintf(&file, "%s/link", temp) < 0) goto exit; - copyfile(src, file, 0, opt | LITE_FOPT_COPYFILE_SYM); + copyfile(src, file, 0, copt); if (errno) goto exit; result = rename(file, dest) != 0; diff --git a/test/copyfile-symlink.c b/test/copyfile-symlink.c index d4c55c6..58de5ee 100644 --- a/test/copyfile-symlink.c +++ b/test/copyfile-symlink.c @@ -65,29 +65,33 @@ int main(void) check_link("copied", "file"); fail_unless(lstat("copied", &dst_st) == 0 && dst_st.st_mtime == times[1].tv_sec); fail_unless(unlink("copied") == 0); - /* Exercise the existing rsync file caller, which requests SYM. */ + /* rsync() follows links unless asked to recreate them. */ fail_unless(rsync("link", "copied", 0, NULL) == 0); + fail_unless(lstat("copied", &dst_st) == 0 && S_ISREG(dst_st.st_mode)); + fail_unless(dst_st.st_size == sizeof(payload)); + fail_unless(unlink("copied") == 0); + fail_unless(rsync("link", "copied", LITE_FOPT_RSYNC_LINKS, NULL) == 0); check_link("copied", "file"); /* Reuse the destination after changing the source link. */ fail_unless(unlink("link") == 0 && symlink("file", "link") == 0); - fail_unless(rsync("link", "out", 0, NULL) == 0); + fail_unless(rsync("link", "out", LITE_FOPT_RSYNC_LINKS, NULL) == 0); check_link("out/link", "file"); fail_unless(unlink("link") == 0 && symlink("copied", "link") == 0); - fail_unless(rsync("link", "out", 0, NULL) == 0); + fail_unless(rsync("link", "out", LITE_FOPT_RSYNC_LINKS, NULL) == 0); check_link("out/link", "copied"); /* A same-path copy must not unlink its source. */ - fail_unless(rsync("link", "link", 0, NULL) == 0); + fail_unless(rsync("link", "link", LITE_FOPT_RSYNC_LINKS, NULL) == 0); check_link("link", "copied"); fail_unless(unlink("out/link") == 0); /* Replacing a regular destination must also retain the source link. */ fail_unless(touch("out/link") == 0); - fail_unless(rsync("link", "out", 0, NULL) == 0); + fail_unless(rsync("link", "out", LITE_FOPT_RSYNC_LINKS, NULL) == 0); check_link("out/link", "copied"); fail_unless(unlink("out/link") == 0); /* A failed replacement must keep both the destination and source. */ fail_unless(mkdir("out/link", 0700) == 0); fail_unless(touch("out/link/keep") == 0); - fail_unless(rsync("link", "out", 0, NULL) != 0); + fail_unless(rsync("link", "out", LITE_FOPT_RSYNC_LINKS, NULL) != 0); fail_unless(fexist("out/link/keep")); check_link("link", "copied"); fail_unless(unlink("out/link/keep") == 0 && rmdir("out/link") == 0); @@ -95,7 +99,7 @@ int main(void) fail_unless(mkdir("protected", 0700) == 0); fail_unless(symlink("original", "protected/link") == 0); fail_unless(chmod("protected", 0500) == 0); - fail_unless(rsync("link", "protected", 0, NULL) != 0); + fail_unless(rsync("link", "protected", LITE_FOPT_RSYNC_LINKS, NULL) != 0); check_link("protected/link", "original"); check_link("link", "copied"); fail_unless(chmod("protected", 0700) == 0); From ef8b17f438699fa1c84f4af2fe400bd884c97764 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 14 Nov 2026 12:47:14 +0100 Subject: [PATCH 3/4] 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); From 2f6a7f15955cf71b94829416b221cced2c67b4e9 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 14 Nov 2026 12:47:14 +0100 Subject: [PATCH 4/4] Update ChangeLog with fcopyfile() fix Signed-off-by: Joachim Wiberg --- ChangeLog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index 8edc447..6844aee 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -15,6 +15,8 @@ All notable changes to the project are documented in this file. - Fix #23: `copyfile()` ignored `LITE_FOPT_COPYFILE_SYM` and always copied what a symlink points to. It now recreates the symlink, also when it is dangling or points to a directory +- Fix #22: `fcopyfile()` truncated binary data at the first NUL byte in + each chunk, and returned success on read and write errors - Fix `copyfile()` not preserving the source file mode when the process umask is more restrictive - Fix `mkpath()`, and `fmkpath()`, not applying the given mode when the