* Reconstruct the full path relative to module_dir
* from F_PATHNAME (path) and f_name (fname). */
char secure_path[MAXPATHLEN];
+ const char *relp;
int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname);
if (slen >= (int)sizeof secure_path) {
io_error |= IOERR_GENERAL;
send_msg_int(MSG_NO_SEND, ndx);
continue;
}
- fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0);
+ /* A module with `path = /` makes F_PATHNAME absolute, so the
+ * joined path starts with '/'; strip leading slashes to a
+ * module-relative path that secure_relative_open accepts (#897). */
+ relp = secure_path;
+ while (*relp == '/')
+ relp++;
+ fd = secure_relative_open(module_dir, relp, O_RDONLY, 0);
} else {
fd = do_open_checklinks(fname);
}
--- /dev/null
+#!/usr/bin/env python3
+# Functional regression: a daemon module with `path = /` (use chroot = no)
+# cannot send ANY file in 3.4.3 -- every read fails with "Invalid argument (22)".
+#
+# Reported as #897 ("Regression in 3.4.3, Invalid argument (22) on all file
+# reads when using native protocol"). Works in 3.4.2, works over a remote
+# shell; only the native (daemon) protocol with an absolute module path breaks.
+#
+# Root cause: the 3.4.3 symlink-race hardening routes the sender's file open
+# through secure_relative_open(module_dir, secure_path, ...) (sender.c), where
+# secure_path = F_PATHNAME + "/" + f_name.
+# With `path = /` the module-relative F_PATHNAME is itself ABSOLUTE, so
+# secure_path starts with '/'. secure_relative_open()'s front door rejects any
+# absolute relpath with EINVAL *before* it ever calls openat2 (matching the
+# reporter's strace: the file is stat'd and access()'d but never opened). The
+# generator then reports "send_files failed to open ...: Invalid argument (22)"
+# and the whole transfer ends in code 23.
+#
+# This is a pure functional regression (no attacker, no symlink): XFAIL until
+# the sender open is made to tolerate an absolute module-root path (the
+# accompanying sender.c fix). Runs at any uid.
+
+import subprocess
+
+from rsyncfns import (
+ SCRATCHDIR, makepath, rmtree, rsync_argv, start_test_daemon, test_fail,
+ write_daemon_conf,
+)
+
+DAEMON_PORT = 12897
+
+# A small source tree under the scratch dir: a file at the served-subdir root
+# and one nested deeper (the bug fails on EVERY file, regardless of depth).
+served = SCRATCHDIR / 'served'
+dst = SCRATCHDIR / 'pulldst'
+rmtree(served)
+rmtree(dst)
+makepath(served / 'sub')
+makepath(dst)
+(served / 'README').write_text("readme-contents\n")
+(served / 'sub' / 'deep.txt').write_text("deep-contents\n")
+
+# Module rooted at the filesystem root, exactly like the report (path = /,
+# use chroot = no). We then request the served subtree by its absolute path
+# with the leading '/' stripped, so the daemon serves $served from "/".
+conf = write_daemon_conf([
+ ('root', {'path': '/', 'read only': 'yes'}),
+])
+url = start_test_daemon(conf, DAEMON_PORT)
+
+served_rel = str(served).lstrip('/') # e.g. tmp/.../served
+proc = subprocess.run(
+ rsync_argv('-a', f'{url}root/{served_rel}/', f'{dst}/'),
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+)
+out = proc.stdout or ''
+print(out)
+
+# Bug present: the sender refuses to open the files with EINVAL(22).
+if 'Invalid argument (22)' in out or ('failed to open' in out and proc.returncode != 0):
+ from rsyncfns import test_xfail
+ test_xfail(
+ "#897: daemon module `path = /` (use chroot = no) cannot send files -- "
+ "`send_files failed to open ...: Invalid argument (22)`. The sender's "
+ "secure_relative_open(module_dir, secure_path) gets an ABSOLUTE "
+ "secure_path (F_PATHNAME is absolute when path=/) and the front door "
+ "rejects absolute relpaths with EINVAL before any openat2. To be closed "
+ "by letting the sender open succeed for an absolute module-root path.")
+
+# Bug fixed (or never present): the files transfer intact.
+if proc.returncode != 0:
+ test_fail(f"daemon pull failed unexpectedly (rc={proc.returncode}); "
+ f"not the #897 EINVAL symptom:\n{out}")
+for rel in ('README', 'sub/deep.txt'):
+ got = dst / rel
+ if not got.is_file():
+ test_fail(f"daemon pull did not deliver {rel} (dst={dst})")
+ if got.read_text() != (served / rel).read_text():
+ test_fail(f"delivered {rel} content differs from source")