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.
This commit is contained in:
Andrew Tridgell committed 2026-08-03 07:10:00 +10:00
1 parent 36be3b4e70
commit d09edb85e6
2 files changed
+64 -11

No files matched your search

+47 -11
View File
@@ -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. */
+17
View File
@@ -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