From d09edb85e66a8c0e6708b28aff63cbbb675b974c Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 3 Aug 2026 07:06:36 +1000 Subject: [PATCH] generator: fall back to a copy when the destination cannot hard-link CAN_HARDLINK_SYMLINK and CAN_HARDLINK_SPECIAL are decided by configure running linkat() on whatever filesystem the build tree happened to sit on. The destination is free to disagree, and one host can hold both answers: macOS builds on APFS, which can hard-link a symlink, and backs up to HFS+, which returns ENOTSUP. A build that said yes had no fallback left. try_dests_non() reported the refusal as a transfer error and returned a matched basis, so the caller created the entry anyway -- correctly -- and the run still exited 23. Every neighbouring case copes: a regular file whose link() fails goes to try_a_copy, and a build compiled without either macro resorts to --copy-dest behaviour. This was the same situation, discovered a little later, and the only one treated as fatal. Take the existing fallback on any refusal. Singling out the "cannot" errnos is not possible: link(2) documents EPERM both for a filesystem with no hard-link support and for an ordinary permission refusal, and FUSE reports ENOSYS for the same thing. It is also what the regular-file path next door has always done (try_dests_reg -> hard_link_one -> try_a_copy), and consistency between the two was the point. Where the errno does matter the surrounding transfer says so anyway: ENOSPC, EDQUOT and EROFS fail the creation independently, EMLINK and EXDEV mean the link was never possible. EIO alone goes unremarked; reporting it would put a line into --link-dest's itemised output, so it is left out on purpose. Returning -3 rather than -2 keeps the caller out of the "already up to date, skip it" arm, which under --link-dest would drop the entry entirely. Both callers give -3 the treatment the compile-time fallback already gets -- clearing itemizing and code -- because try_dests_non() has itemised the match itself and would otherwise report the entry twice. The fallback is silent, matching a build that cannot link these at compile time; documented under --link-dest instead. --- generator.c | 58 +++++++++++++++++++++++++++++++++++++++++++---------- rsync.1.md | 17 ++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/generator.c b/generator.c index fde4501c..7e5ad60a 100644 --- a/generator.c +++ b/generator.c @@ -1198,8 +1198,10 @@ got_nothing_for_ya: } /* This is only called for non-regular files. We return -2 if we've finished - * handling the file, or -1 if no dest-linking occurred, or a non-negative - * value if we found an alternate basis file. */ + * handling the file, -3 if we matched one but the destination refused to + * hard-link it (the caller creates it instead, and must not report it again), + * or -1 if no dest-linking occurred, or a non-negative value if we found an + * alternate basis file. */ static int try_dests_non(struct file_struct *file, char *fname, int ndx, char *cmpbuf, stat_x *sxp, int itemizing, enum logcode code) @@ -1254,6 +1256,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, } if (match_level == 3) { + int cannot_hardlink = 0; #ifdef SUPPORT_HARD_LINKS if (alt_dest_type == LINK_DEST #ifndef CAN_HARDLINK_SYMLINK @@ -1264,12 +1267,29 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, #endif && !S_ISDIR(file->mode)) { if (do_link_at(cmpbuf, fname) < 0) { - rsyserr(FERROR_XFER, errno, - "failed to hard-link %s with %s", - cmpbuf, fname); - return j; - } - if (preserve_hard_links && F_IS_HLINKED(file)) + /* CAN_HARDLINK_SYMLINK/_SPECIAL answer for whatever + * filesystem the build tree sat on; the destination is + * free to disagree, and one host can hold both (macOS + * builds on APFS, backs up to HFS+). A refusal here is + * that same answer arriving late, so fall back to a copy + * as a build without the macro does -- the caller creates + * the entry either way, so failing the transfer only cost + * the exit status. + * + * Every errno, as the regular-file path next door already + * does (try_dests_reg -> hard_link_one -> try_a_copy). + * Picking out the "cannot" errnos is not possible anyway: + * link(2) documents EPERM both for a filesystem with no + * hard-link support and for an ordinary permission + * refusal, and FUSE reports ENOSYS for the same thing. + * + * The rest report themselves: ENOSPC/EDQUOT/EROFS fail the + * copy too, EMLINK and EXDEV mean it was never linkable. + * EIO alone goes unremarked, deliberately -- a diagnostic + * here lands in --link-dest's itemised output. */ + cannot_hardlink = 1; + match_level = 2; + } else if (preserve_hard_links && F_IS_HLINKED(file)) finish_hard_link(file, fname, ndx, NULL, itemizing, code, -1); } else #endif @@ -1285,7 +1305,11 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, rprintf(FCLIENT, "%s%s is uptodate\n", fname, ftype == FT_DIR ? "/" : ""); } - return -2; + /* -2 tells the caller the entry is already up to date, which for + * --link-dest means "skip it". We could not link it, so say -3 + * instead: no caller claims that, and the fall-through creates the + * entry -- the same place a build without the macro ends up. */ + return cannot_hardlink ? -3 : -2; } return j; @@ -1953,7 +1977,14 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } } else if (basis_dir[0] != NULL) { int j = try_dests_non(file, fname, ndx, fnamecmpbuf, &sx, itemizing, code); - if (j == -2) { + if (j == -3) { + /* The destination cannot hard-link this type. Land exactly + * where a build without CAN_HARDLINK_SYMLINK lands: create + * the entry, but leave the reporting to the itemisation + * try_dests_non() already emitted. */ + itemizing = 0; + code = FNONE; + } else if (j == -2) { #ifndef CAN_HARDLINK_SYMLINK if (alt_dest_type == LINK_DEST) { /* Resort to --copy-dest behavior. */ @@ -2032,7 +2063,12 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, } } else if (basis_dir[0] != NULL) { int j = try_dests_non(file, fname, ndx, fnamecmpbuf, &sx, itemizing, code); - if (j == -2) { + if (j == -3) { + /* As above: a destination that cannot hard-link this type + * behaves like a build without CAN_HARDLINK_SPECIAL. */ + itemizing = 0; + code = FNONE; + } else if (j == -2) { #ifndef CAN_HARDLINK_SPECIAL if (alt_dest_type == LINK_DEST) { /* Resort to --copy-dest behavior. */ diff --git a/rsync.1.md b/rsync.1.md index 9539d5c3..01017afd 100644 --- a/rsync.1.md +++ b/rsync.1.md @@ -2926,6 +2926,23 @@ sign) if you want the local shell to expand it. attributes updated. If a match is not found, a basis file from one of the _DIRs_ will be selected to try to speed up the transfer. + Not every filesystem can hard-link a symlink, a device node, a FIFO or a + socket, and the destination need not agree with the one rsync was built on + -- macOS builds on APFS, which can, and may write to HFS+, which cannot. + Where the destination refuses to link such an entry, it is copied instead + and the transfer carries on, so only that entry loses the space saving. + This applies to any refusal, because the error alone does not identify one: + link(2) reports `EPERM` both for a filesystem without hard links and for an + ordinary permission refusal. Regular files have always behaved this way. + + One case is not covered. With [`--hard-links`](#opt) (`-H`), a group of + such entries hard-linked to *each other* in the source needs a second link, + from the first member to the rest, inside the destination itself. Where + that link is refused too -- a destination that cannot hard-link the type at + all -- the members after the first are not created and the transfer fails. + A `--link-dest` on another filesystem is fine: only the link to _DIR_ is + impossible there, and the ones within the destination still succeed. + This option works best when copying into an empty destination hierarchy, as existing files may get their attributes tweaked, and that can affect alternate destination files via hard-links. Also, itemizing of changes can