testsuite: shared harness, helpers and unit-test programs

The t_rename_secure / t_symlink_secure / t_acl unit harnesses (C), the rsyncfns.py
helper library (daemon fixtures, symlink matrix, tree compare, xattr/ACL drivers),
runtests.py, and the rsync_proto/xrsync/cmptree/mkvariety helper scripts that the
security tests build on.
This commit is contained in:
Andrew Tridgell committed 2026-07-20 14:05:31 +10:00
1 parent 88cee08963
commit 1346bc623f
13 files changed
+3566 -136

No files matched your search

+50 -12
View File
@@ -27,6 +27,7 @@ import concurrent.futures
import fnmatch
import glob
import os
import signal
import subprocess
import sys
import threading
@@ -164,11 +165,30 @@ def get_testuser():
return os.environ.get('LOGNAME', os.environ.get('USER', 'UNKNOWN'))
def _move_aside(path):
"""Rename an un-removable directory to a unique sibling so its name is free.
A rename-storm symlink-race test can corrupt a directory on some filesystems
(OpenBSD FFS soft-updates can leave an "empty" dir that still reports
ENOTEMPTY/EPERM and only fsck clears). `rm -rf` then can't remove it, but
renaming the top dir aside succeeds even with a corrupted descendant, freeing
the original name for a clean scratchdir."""
n = 0
while os.path.exists(f"{path}.corrupt.{os.getpid()}.{n}"):
n += 1
try:
os.rename(path, f"{path}.corrupt.{os.getpid()}.{n}")
except OSError:
pass
def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef):
"""Prepare a scratch directory for a test."""
if os.path.isdir(scratchdir):
subprocess.run(['chmod', '-R', 'u+rwX', scratchdir], capture_output=True)
subprocess.run(['rm', '-rf', scratchdir], capture_output=True)
if os.path.isdir(scratchdir):
_move_aside(scratchdir) # rm -rf left corrupted debris; don't inherit it
os.makedirs(scratchdir, exist_ok=True)
if setfacl_nodef:
subprocess.run(setfacl_nodef + [scratchdir], capture_output=True)
@@ -319,18 +339,36 @@ def run_one_test(testscript, testbase, scratchdir, base_env, timeout,
cmd = ['sh', '-e', testscript]
logfile = os.path.join(scratchdir, 'test.log')
try:
with open(logfile, 'w') as log:
proc = subprocess.run(
cmd,
stdout=log, stderr=subprocess.STDOUT,
env=env, timeout=timeout,
cwd=env.get('TOOLDIR', '.')
)
result = proc.returncode
except subprocess.TimeoutExpired:
result = 1
with open(logfile, 'a') as log:
with open(logfile, 'w') as log:
# start_new_session: run the test driver as its own session/group leader
# so the daemon, clients and flipper it spawns inherit that group. A
# timeout then killpg's the whole tree (not just the driver), and the
# lock-file sweep can reap a SIGKILLed run's stranded group the same way.
proc = subprocess.Popen(
cmd,
stdout=log, stderr=subprocess.STDOUT,
env=env, cwd=env.get('TOOLDIR', '.'),
start_new_session=True,
)
try:
result = proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Reap the whole session group, but only if the driver really is its
# own group leader (start_new_session took) and that group isn't ours
# -- killpg of our own group would take down the runner.
try:
pgid = os.getpgid(proc.pid)
except OSError:
pgid = -1
if pgid == proc.pid and pgid != os.getpgrp():
try:
os.killpg(pgid, signal.SIGKILL)
except OSError:
proc.kill()
else:
proc.kill()
proc.wait()
result = 1
log.write(f"\nTIMEOUT: test took over {timeout} seconds\n")
# Build output text
+532
View File
@@ -0,0 +1,532 @@
/*
* Unit test for lib/acl.c.
*
* Validates the fd- and at-based POSIX ACL get/set/delete in lib/acl.c against
* the system libacl, used here as an oracle: we set an ACL via one and read it
* back via the other (both directions), round-trip through lib/acl.c, and check
* the default-ACL and delete paths. We deliberately do NOT try to reproduce
* libacl's symlink-following races -- we only compare functional behaviour.
*
* Not linked into rsync itself. Exits 0 if all checks pass, 1 on any failure,
* 77 to skip (built without SUPPORT_ACL_FD, no libacl, or a scratch filesystem
* without ACL support).
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2 as
* published by the Free Software Foundation.
*/
#include "rsync.h"
#include "lib/acl.h"
#include <stdio.h>
#ifndef SUPPORT_ACL_FD
int main(int argc, char *argv[])
{
(void)argc;
(void)argv;
fprintf(stderr, "t_acl: built without SUPPORT_ACL_FD -- skipping\n");
return 77;
}
#else
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/acl.h>
#ifdef HAVE_ACL_LIBACL_H
#include <acl/libacl.h> /* acl_get_perm() */
#endif
#define MAX_ENT 16
static int errs = 0;
static const char *scratch;
static void ok(int cond, const char *label)
{
if (cond)
fprintf(stderr, "OK %s\n", label);
else {
fprintf(stderr, "FAIL %s\n", label);
errs++;
}
}
static void dump_entries(const char *pfx, const rsync_acl_ent *e, int n)
{
int i;
for (i = 0; i < n; i++)
fprintf(stderr, " %s tag=0x%02x perm=%o id=%u\n",
pfx, e[i].tag, e[i].perm,
e[i].id == RACL_UNDEFINED_ID ? (unsigned)-1 : e[i].id);
}
static int ent_cmp(const void *a, const void *b)
{
const rsync_acl_ent *x = a, *y = b;
if (x->tag != y->tag)
return x->tag < y->tag ? -1 : 1;
if (x->id != y->id)
return x->id < y->id ? -1 : 1;
return 0;
}
static void canon(rsync_acl_ent *e, int n)
{
if (n > 1)
qsort(e, n, sizeof e[0], ent_cmp);
}
static int entries_equal(rsync_acl_ent *a, int na, rsync_acl_ent *b, int nb)
{
int i;
canon(a, na);
canon(b, nb);
if (na != nb)
return 0;
for (i = 0; i < na; i++) {
if (a[i].tag != b[i].tag || a[i].perm != b[i].perm)
return 0;
if ((a[i].tag == RACL_USER || a[i].tag == RACL_GROUP)
&& a[i].id != b[i].id)
return 0;
}
return 1;
}
/* === libacl oracle helpers === */
static uint16_t racl_from_tag(acl_tag_t tag)
{
switch (tag) {
case ACL_USER_OBJ: return RACL_USER_OBJ;
case ACL_USER: return RACL_USER;
case ACL_GROUP_OBJ: return RACL_GROUP_OBJ;
case ACL_GROUP: return RACL_GROUP;
case ACL_MASK: return RACL_MASK;
case ACL_OTHER: return RACL_OTHER;
}
return 0;
}
static acl_tag_t tag_from_racl(uint16_t tag)
{
switch (tag) {
case RACL_USER_OBJ: return ACL_USER_OBJ;
case RACL_USER: return ACL_USER;
case RACL_GROUP_OBJ: return ACL_GROUP_OBJ;
case RACL_GROUP: return ACL_GROUP;
case RACL_MASK: return ACL_MASK;
case RACL_OTHER: return ACL_OTHER;
}
return 0;
}
/* Convert a libacl acl_t to our neutral entry array. Returns count or -1. */
static int libacl_to_entries(acl_t acl, rsync_acl_ent *out, int max)
{
acl_entry_t e;
int n = 0, r;
for (r = acl_get_entry(acl, ACL_FIRST_ENTRY, &e); r == 1;
r = acl_get_entry(acl, ACL_NEXT_ENTRY, &e)) {
acl_tag_t tag;
acl_permset_t ps;
uint16_t perm = 0;
if (n >= max)
return -1;
if (acl_get_tag_type(e, &tag) != 0 || acl_get_permset(e, &ps) != 0)
return -1;
if (acl_get_perm(ps, ACL_READ) > 0)
perm |= 4;
if (acl_get_perm(ps, ACL_WRITE) > 0)
perm |= 2;
if (acl_get_perm(ps, ACL_EXECUTE) > 0)
perm |= 1;
out[n].tag = racl_from_tag(tag);
out[n].perm = perm;
out[n].id = RACL_UNDEFINED_ID;
if (tag == ACL_USER || tag == ACL_GROUP) {
void *q = acl_get_qualifier(e);
if (q) {
out[n].id = *(id_t *)q;
acl_free(q);
}
}
n++;
}
if (r < 0)
return -1;
canon(out, n);
return n;
}
/* Build a libacl acl_t from our neutral entries. */
static acl_t entries_to_libacl(const rsync_acl_ent *ents, int n)
{
acl_t acl = acl_init(n);
int i;
if (!acl)
return NULL;
for (i = 0; i < n; i++) {
acl_entry_t e;
acl_permset_t ps;
if (acl_create_entry(&acl, &e) != 0)
goto fail;
if (acl_set_tag_type(e, tag_from_racl(ents[i].tag)) != 0)
goto fail;
if (acl_get_permset(e, &ps) != 0 || acl_clear_perms(ps) != 0)
goto fail;
if ((ents[i].perm & 4) && acl_add_perm(ps, ACL_READ) != 0)
goto fail;
if ((ents[i].perm & 2) && acl_add_perm(ps, ACL_WRITE) != 0)
goto fail;
if ((ents[i].perm & 1) && acl_add_perm(ps, ACL_EXECUTE) != 0)
goto fail;
if (acl_set_permset(e, ps) != 0)
goto fail;
if (ents[i].tag == RACL_USER || ents[i].tag == RACL_GROUP) {
id_t id = ents[i].id;
if (acl_set_qualifier(e, &id) != 0)
goto fail;
}
}
return acl;
fail:
acl_free(acl);
return NULL;
}
static int libacl_get(const char *path, acl_type_t type, rsync_acl_ent *out, int max)
{
acl_t acl = acl_get_file(path, type);
int n;
if (!acl)
return -1;
n = libacl_to_entries(acl, out, max);
acl_free(acl);
return n;
}
static int libacl_set(const char *path, acl_type_t type, const rsync_acl_ent *ents, int n)
{
acl_t acl = entries_to_libacl(ents, n);
int rc;
if (!acl)
return -1;
rc = acl_set_file(path, type, acl);
acl_free(acl);
return rc;
}
/* === scratch helpers === */
static char *acl_path(const char *name)
{
static char buf[4096];
snprintf(buf, sizeof buf, "%s/%s", scratch, name);
return buf;
}
static int acl_mkfile(const char *name)
{
char *p = acl_path(name);
int fd = open(p, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0)
close(fd);
return fd < 0 ? -1 : 0;
}
static int acl_mkdir(const char *name)
{
return mkdir(acl_path(name), 0755);
}
/* Open a held fd the way set_file_attrs does (REG/DIR, NOFOLLOW). */
static int open_held(const char *name)
{
return open(acl_path(name), O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
}
/* === comparison tests for one ACL shape === */
static void cmp_via_fd(const char *name, int want_default,
const rsync_acl_ent *ents, int n, const char *tag)
{
char label[256];
char *path = acl_path(name);
acl_type_t type = want_default ? ACL_TYPE_DEFAULT : ACL_TYPE_ACCESS;
rsync_acl_ent got[MAX_ENT], oracle[MAX_ENT];
rsync_acl_ent *lib_ents = NULL;
int gc = 0, oc, fd;
/* 1. set via lib (fd) -> read via libacl */
fd = open_held(name);
snprintf(label, sizeof label, "%s: open held fd", tag);
ok(fd >= 0, label);
if (fd < 0)
return;
snprintf(label, sizeof label, "%s: xacl_set_fd", tag);
ok(xacl_set_fd(fd, want_default, ents, n) == 0, label);
oc = libacl_get(path, type, oracle, MAX_ENT);
memcpy(got, ents, n * sizeof ents[0]);
snprintf(label, sizeof label, "%s: libacl reads back what xacl_set_fd wrote", tag);
if (!entries_equal(got, n, oracle, oc)) {
dump_entries("set ", got, n);
dump_entries("got ", oracle, oc < 0 ? 0 : oc);
}
ok(oc == n && entries_equal(got, n, oracle, oc), label);
/* 2. set via libacl -> read via lib (fd) */
snprintf(label, sizeof label, "%s: libacl_set", tag);
ok(libacl_set(path, type, ents, n) == 0, label);
snprintf(label, sizeof label, "%s: xacl_get_fd reads back what libacl wrote", tag);
if (xacl_get_fd(fd, want_default, &lib_ents, &gc) == 0) {
memcpy(got, ents, n * sizeof ents[0]);
if (!entries_equal(got, n, lib_ents, gc)) {
dump_entries("set ", got, n);
dump_entries("got ", lib_ents, gc);
}
ok(gc == n && entries_equal(got, n, lib_ents, gc), label);
} else
ok(0, label);
if (lib_ents)
free(lib_ents);
lib_ents = NULL;
/* 3. round-trip lib set -> lib get */
snprintf(label, sizeof label, "%s: xacl_set_fd/xacl_get_fd round-trip", tag);
if (xacl_set_fd(fd, want_default, ents, n) == 0
&& xacl_get_fd(fd, want_default, &lib_ents, &gc) == 0) {
memcpy(got, ents, n * sizeof ents[0]);
ok(gc == n && entries_equal(got, n, lib_ents, gc), label);
} else
ok(0, label);
if (lib_ents)
free(lib_ents);
close(fd);
}
static void cmp_via_at(const char *name, int want_default,
const rsync_acl_ent *ents, int n, const char *tag)
{
char label[256];
char *path = acl_path(name);
acl_type_t type = want_default ? ACL_TYPE_DEFAULT : ACL_TYPE_ACCESS;
rsync_acl_ent got[MAX_ENT], oracle[MAX_ENT];
rsync_acl_ent *lib_ents = NULL;
int gc = 0, oc, dirfd;
dirfd = open(scratch, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
snprintf(label, sizeof label, "%s: open scratch dirfd", tag);
ok(dirfd >= 0, label);
if (dirfd < 0)
return;
snprintf(label, sizeof label, "%s: xacl_set_at", tag);
ok(xacl_set_at(dirfd, name, want_default, ents, n) == 0, label);
oc = libacl_get(path, type, oracle, MAX_ENT);
memcpy(got, ents, n * sizeof ents[0]);
snprintf(label, sizeof label, "%s: libacl reads back what xacl_set_at wrote", tag);
ok(oc == n && entries_equal(got, n, oracle, oc), label);
snprintf(label, sizeof label, "%s: libacl_set", tag);
ok(libacl_set(path, type, ents, n) == 0, label);
snprintf(label, sizeof label, "%s: xacl_get_at reads back what libacl wrote", tag);
if (xacl_get_at(dirfd, name, want_default, &lib_ents, &gc) == 0) {
memcpy(got, ents, n * sizeof ents[0]);
ok(gc == n && entries_equal(got, n, lib_ents, gc), label);
} else
ok(0, label);
if (lib_ents)
free(lib_ents);
close(dirfd);
}
/* === scenarios === */
#define U(p) { RACL_USER_OBJ, p, RACL_UNDEFINED_ID }
#define G(p) { RACL_GROUP_OBJ, p, RACL_UNDEFINED_ID }
#define M(p) { RACL_MASK, p, RACL_UNDEFINED_ID }
#define O(p) { RACL_OTHER, p, RACL_UNDEFINED_ID }
#define NU(i,p) { RACL_USER, p, i }
#define NG(i,p) { RACL_GROUP, p, i }
static const rsync_acl_ent A1[] = { U(6), NU(12345,4), G(4), M(6), O(4) };
static const rsync_acl_ent A2[] = { U(7), NU(1000,6), NU(2000,5), G(5), NG(100,4), NG(200,1), M(7), O(0) };
static const rsync_acl_ent A3[] = { U(7), NU(4000000000U,5), G(5), M(7), O(4) };
static const rsync_acl_ent A4[] = { U(7), NU(0,0), G(0), M(4), O(0) };
static const rsync_acl_ent D1[] = { U(7), G(5), O(5) };
static const rsync_acl_ent D2[] = { U(7), NU(1000,6), G(5), NG(100,4), M(6), O(0) };
#define NELEM(a) ((int)(sizeof (a) / sizeof (a)[0]))
static void run_access(const char *base, const rsync_acl_ent *e, int n, const char *tag)
{
char fdname[128], atname[128];
char fdtag[160], attag[160];
snprintf(fdname, sizeof fdname, "%s_fd", base);
snprintf(atname, sizeof atname, "%s_at", base);
snprintf(fdtag, sizeof fdtag, "access %s [fd]", tag);
snprintf(attag, sizeof attag, "access %s [at]", tag);
if (acl_mkfile(fdname) == 0)
cmp_via_fd(fdname, 0, e, n, fdtag);
if (xacl_at_available()) {
if (acl_mkfile(atname) == 0)
cmp_via_at(atname, 0, e, n, attag);
}
}
static void run_default(const char *base, const rsync_acl_ent *e, int n, const char *tag)
{
char fdname[128], atname[128];
char fdtag[160], attag[160];
snprintf(fdname, sizeof fdname, "%s_fd", base);
snprintf(atname, sizeof atname, "%s_at", base);
snprintf(fdtag, sizeof fdtag, "default %s [fd]", tag);
snprintf(attag, sizeof attag, "default %s [at]", tag);
if (acl_mkdir(fdname) == 0)
cmp_via_fd(fdname, 1, e, n, fdtag);
if (xacl_at_available()) {
if (acl_mkdir(atname) == 0)
cmp_via_at(atname, 1, e, n, attag);
}
}
/* delete of a default ACL + the "no explicit ACL" / errno behaviour */
static void run_misc(void)
{
rsync_acl_ent *ents = NULL;
int n = 0, fd;
char *p;
/* default-ACL delete */
if (acl_mkdir("deldir") == 0) {
fd = open_held("deldir");
ok(fd >= 0, "misc: open deldir fd");
if (fd >= 0) {
ok(xacl_set_fd(fd, 1, D2, NELEM(D2)) == 0, "misc: set default to delete");
ok(xacl_del_default_fd(fd) == 0, "misc: xacl_del_default_fd");
/* libacl returns an empty (0-entry) default ACL after delete */
{
acl_t a = acl_get_file(acl_path("deldir"), ACL_TYPE_DEFAULT);
int cnt = -1;
if (a) {
acl_entry_t e;
cnt = acl_get_entry(a, ACL_FIRST_ENTRY, &e);
acl_free(a);
}
ok(cnt == 0, "misc: default ACL is empty after delete");
}
/* deleting again is still success */
ok(xacl_del_default_fd(fd) == 0, "misc: xacl_del_default_fd idempotent");
close(fd);
}
}
/* a freshly-created file has no explicit access ACL xattr -> count 0 */
if (acl_mkfile("plainfile") == 0) {
fd = open_held("plainfile");
ok(fd >= 0, "misc: open plainfile fd");
if (fd >= 0) {
int rc = xacl_get_fd(fd, 0, &ents, &n);
ok(rc == 0 && n == 0, "misc: xacl_get_fd on mode-only file -> no entries");
if (ents)
free(ents);
ents = NULL;
/* no default ACL on a regular file -> empty */
rc = xacl_get_fd(fd, 1, &ents, &n);
ok(rc == 0 && n == 0, "misc: xacl_get_fd default on regular file -> no entries");
if (ents)
free(ents);
close(fd);
}
}
/* at-variant: leaf NOFOLLOW must not touch a symlink target */
if (xacl_at_available()) {
int dirfd;
acl_mkfile("nofollow_target");
p = acl_path("nofollow_link");
unlink(p);
if (symlink("nofollow_target", p) == 0
&& (dirfd = open(scratch, O_RDONLY | O_DIRECTORY | O_CLOEXEC)) >= 0) {
rsync_acl_ent before[MAX_ENT], after[MAX_ENT];
int bc, ac;
/* give the target a distinctive ACL via libacl */
libacl_set(acl_path("nofollow_target"), ACL_TYPE_ACCESS, A1, NELEM(A1));
bc = libacl_get(acl_path("nofollow_target"), ACL_TYPE_ACCESS, before, MAX_ENT);
/* attempt to set through the symlink leaf with NOFOLLOW: must fail */
errno = 0;
ok(xacl_set_at(dirfd, "nofollow_link", 0, A2, NELEM(A2)) != 0,
"misc: xacl_set_at on symlink leaf is refused");
ac = libacl_get(acl_path("nofollow_target"), ACL_TYPE_ACCESS, after, MAX_ENT);
ok(bc == ac && entries_equal(before, bc, after, ac),
"misc: symlink target ACL unchanged by NOFOLLOW set");
close(dirfd);
}
}
}
int main(int argc, char *argv[])
{
char *p;
if (argc > 1)
scratch = argv[1];
else
scratch = "/tmp";
/* Probe filesystem ACL support: set a trivial ACL via libacl. */
if (acl_mkfile(".acl_probe") != 0) {
fprintf(stderr, "t_acl: cannot create scratch file in %s\n", scratch);
return 77;
}
p = acl_path(".acl_probe");
if (libacl_set(p, ACL_TYPE_ACCESS, A1, NELEM(A1)) != 0) {
if (errno == EOPNOTSUPP || errno == ENOTSUP || errno == ENOSYS) {
fprintf(stderr, "t_acl: %s has no ACL support -- skipping\n", scratch);
unlink(p);
return 77;
}
fprintf(stderr, "t_acl: probe acl_set_file failed: %s\n", strerror(errno));
unlink(p);
return 77;
}
unlink(p);
fprintf(stderr, "t_acl: scratch=%s, setxattrat=%s\n",
scratch, xacl_at_available() ? "yes" : "no");
run_access("a1", A1, NELEM(A1), "named-user+mask");
run_access("a2", A2, NELEM(A2), "multi-named+mask");
run_access("a3", A3, NELEM(A3), "large-uid");
run_access("a4", A4, NELEM(A4), "zero-perms");
run_default("d1", D1, NELEM(D1), "minimal");
run_default("d2", D2, NELEM(D2), "named+mask");
run_misc();
fprintf(stderr, "t_acl: %d failure(s)\n", errs);
return errs ? 1 : 0;
}
#endif /* SUPPORT_ACL_FD */
+38 -67
View File
@@ -17,11 +17,6 @@
#include <sys/stat.h>
#if defined(__linux__) && defined(HAVE_OPENAT2)
#include <sys/syscall.h>
#include <linux/openat2.h>
#endif
int dry_run = 0;
int am_root = 0;
int am_sender = 0;
@@ -35,42 +30,18 @@ short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];
static int errs = 0;
/* Probe the running kernel for the RESOLVE_BENEATH-equivalent confinement
* that secure_relative_open() prefers over the per-component O_NOFOLLOW
* walk. Returns 1 if either openat2(RESOLVE_BENEATH) on Linux 5.6+ or
* openat(O_RESOLVE_BENEATH) on FreeBSD 13+ / macOS 15+ is honoured by
* the running kernel, 0 otherwise. The probe opens "." (a directory
* the helper has just chdir'd into) so it can't fail for any reason
* other than the kernel rejecting the requested confinement flag. */
static int kernel_resolve_beneath_supported(void)
/* Does do_chmod_at()'s leaf handling refuse to follow a symlink at the final
* component? Yes wherever AT_SYMLINK_NOFOLLOW exists; otherwise the wrapper
* falls back to a following fchmodat() (documented limitation). Mirrors the
* #ifdef ladder in do_fchmodat_nofollow. */
static int leaf_chmod_nofollow_supported(void)
{
#if (defined(__linux__) && defined(HAVE_OPENAT2)) || defined(O_RESOLVE_BENEATH)
int fd;
#endif
#if defined(__linux__) && defined(HAVE_OPENAT2)
if (openat2_usable()) {
struct open_how how;
memset(&how, 0, sizeof how);
how.flags = O_RDONLY | O_DIRECTORY;
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS;
fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how);
if (fd >= 0) {
close(fd);
return 1;
}
/* ENOSYS = kernel < 5.6 or openat2 seccomp-blocked. Fall through to the O_RESOLVE_BENEATH
* probe in case we're a Linux build running on a kernel that
* gained O_RESOLVE_BENEATH via some out-of-tree backport. */
}
#endif
#ifdef O_RESOLVE_BENEATH
fd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH);
if (fd >= 0) {
close(fd);
return 1;
}
#endif
#if defined AT_SYMLINK_NOFOLLOW
return 1;
#else
return 0;
#endif
}
static void check(const char *label, int actual_rc, int expect_ok,
@@ -78,7 +49,9 @@ static void check(const char *label, int actual_rc, int expect_ok,
{
struct stat st;
int got_ok = (actual_rc == 0);
if (got_ok != expect_ok) {
/* expect_ok < 0: rc is platform-dependent, don't assert it (leaf-symlink
* scenario); only the target-mode check below is portable. */
if (expect_ok >= 0 && got_ok != expect_ok) {
fprintf(stderr, "FAIL [%s]: rc=%d errno=%d (%s), expected %s\n",
label, actual_rc, errno, strerror(errno),
expect_ok ? "success" : "rejection");
@@ -130,35 +103,18 @@ int main(int argc, char **argv)
* files to mode 0600 so we have a clean baseline to compare.
*/
/* Scenario A: legitimate parent dir-symlink.
/* Scenario A: legitimate parent dir-symlink within the tree.
*
* On platforms whose kernel offers RESOLVE_BENEATH-equivalent
* confinement (Linux 5.6+ openat2, FreeBSD 13+ / macOS 15+
* O_RESOLVE_BENEATH), the within-tree symlink is followed and
* the chmod must succeed.
*
* On platforms that fall back to the per-component O_NOFOLLOW
* walk (OpenBSD, NetBSD, Solaris, older Cygwin, HPE NonStop,
* and pre-5.6 Linux), every symlink is rejected -- including
* this legitimate one. That's a real platform limitation (the
* same one that causes the #715 regression there) and the
* expected outcome is rejection.
*
* Detect at runtime and expect accordingly. The other three
* scenarios behave identically on both code paths and need no
* adjustment. */
int kernel_has_rb = kernel_resolve_beneath_supported();
fprintf(stderr, "INFO: kernel RESOLVE_BENEATH-equivalent confinement: %s\n",
kernel_has_rb ? "available" : "not available (per-component fallback)");
* The within-tree symlink is followed and the chmod must succeed on
* every platform: the kernel RESOLVE_BENEATH paths (Linux 5.6+ openat2,
* FreeBSD 13+ / macOS 15+ O_RESOLVE_BENEATH) and, since the #715/-K
* fallback fix, the per-component O_NOFOLLOW walk too (OpenBSD, NetBSD,
* Solaris, older Cygwin, HPE NonStop, pre-5.6 Linux) -- which now follows
* an in-tree directory symlink whose target is relative and ".."-free.
* Escapes are still rejected on both paths (Scenario B). */
int rc = do_chmod_at("inside_link/sentinel", 0640);
if (kernel_has_rb) {
check("A: legit dir-symlink within tree (kernel confined)",
rc, 1, "realdir/sentinel", 0640);
} else {
check("A: legit dir-symlink within tree (per-component fallback rejects)",
rc, 0, "realdir/sentinel", 0600);
}
check("A: legit dir-symlink within tree (followed)",
rc, 1, "realdir/sentinel", 0640);
/* Scenario B: parent symlink escapes the tree -- chmod must be
* rejected and the outside file's mode must be unchanged. */
@@ -179,6 +135,21 @@ int main(int argc, char **argv)
check("D: top-level file, no parent component",
rc, 1, "topfile", 0640);
/* Scenario E: the LEAF component is an escaping symlink -- the chmod-
* specific TOCTOU, distinct from the parent races A/B. realdir is a real
* directory, isolating the O_NOFOLLOW leaf guard. rc is platform-dependent
* (refused on Linux, lchmod-the-symlink on *BSD/macOS), so assert only that
* the outside target's mode is unchanged. */
if (leaf_chmod_nofollow_supported()) {
rc = do_chmod_at("realdir/leaflink", 0666);
check("E: leaf component is an escaping symlink (must not be followed)",
rc, -1, "../trap/sentinel", 0600);
} else {
fprintf(stderr, "INFO: leaf-nofollow chmod unsupported here; "
"do_chmod_at follows a leaf symlink (documented limitation), "
"skipping scenario E\n");
}
if (errs)
fprintf(stderr, "%d failure(s)\n", errs);
return errs ? 1 : 0;
+206
View File
@@ -0,0 +1,206 @@
/*
* Test harness for do_rename_at(): a mixed top-level/slashed rename must still
* resolve the slashed side's parent under secure_relative_open() rather than
* fall back to plain rename(). Not linked into rsync. GPL version 2.
*/
#include "rsync.h"
#include <sys/stat.h>
int dry_run = 0;
int am_root = 0;
int am_sender = 0;
int read_only = 0;
int list_only = 0;
int copy_links = 0;
int copy_unsafe_links = 0;
extern int am_daemon, am_chrooted;
short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];
static int errs = 0;
#ifdef AT_FDCWD
/* The 3.4.3 bug: if either side has no slash the whole op fell back to plain
* rename(), leaving the slashed side's parent outside secure_relative_open(). */
static int vulnerable_mixed_rename_at(const char *old_path, const char *new_path)
{
const char *old_slash, *new_slash;
if (!old_path || !*old_path || *old_path == '/'
|| !new_path || !*new_path || *new_path == '/')
return do_rename(old_path, new_path);
old_slash = strrchr(old_path, '/');
new_slash = strrchr(new_path, '/');
if (!old_slash || !new_slash)
return do_rename(old_path, new_path);
return do_rename_at(old_path, new_path);
}
#endif
static void check_exists(const char *label, const char *path, int expect_exists)
{
int exists = access(path, F_OK) == 0;
if (exists != expect_exists) {
fprintf(stderr, "FAIL [%s]: %s %s, expected %s\n",
label, path, exists ? "exists" : "does not exist",
expect_exists ? "exists" : "does not exist");
errs++;
return;
}
fprintf(stderr, "OK [%s]: %s %s\n",
label, path, exists ? "exists" : "does not exist");
}
static void check_rename(const char *label, const char *old_path,
const char *new_path, int expect_ok)
{
int rc;
int got_ok;
int saved_errno;
errno = 0;
rc = do_rename_at(old_path, new_path);
saved_errno = errno;
got_ok = rc == 0;
if (got_ok != expect_ok) {
fprintf(stderr, "FAIL [%s]: rename %s -> %s rc=%d errno=%d (%s), expected %s\n",
label, old_path, new_path, rc, saved_errno,
strerror(saved_errno), expect_ok ? "success" : "rejection");
errs++;
return;
}
fprintf(stderr, "OK [%s]: rename %s -> %s %s\n",
label, old_path, new_path, expect_ok ? "succeeded" : "rejected");
}
static void check_vulnerable_rename(const char *label, const char *old_path,
const char *new_path)
{
#ifdef AT_FDCWD
int rc;
int saved_errno;
errno = 0;
rc = vulnerable_mixed_rename_at(old_path, new_path);
saved_errno = errno;
if (rc != 0) {
fprintf(stderr, "FAIL [%s]: vulnerable rename %s -> %s rc=%d errno=%d (%s), expected escape\n",
label, old_path, new_path, rc, saved_errno,
strerror(saved_errno));
errs++;
return;
}
fprintf(stderr, "OK [%s]: vulnerable rename %s -> %s escaped\n",
label, old_path, new_path);
#else
fprintf(stderr, "SKIP [%s]: AT_FDCWD not available\n", label);
#endif
}
static int run_escape_poc(const char *module_dir)
{
#ifndef AT_FDCWD
fprintf(stderr, "SKIP: AT_FDCWD not available\n");
return 77;
#else
if (chdir(module_dir) < 0) {
perror("chdir");
return 2;
}
am_daemon = 1;
am_chrooted = 0;
check_vulnerable_rename("P1: 3.4.3-style top-level source to escaping destination parent",
"poc-top-to-escape", "escape_link/vuln-created");
check_exists("P1 source consumed", "poc-top-to-escape", 0);
check_exists("P1 outside destination created", "../trap/vuln-created", 1);
check_vulnerable_rename("P2: 3.4.3-style escaping source parent to top-level destination",
"escape_link/poc-outside-source", "vuln-stolen");
check_exists("P2 outside source consumed", "../trap/poc-outside-source", 0);
check_exists("P2 destination created in module", "vuln-stolen", 1);
check_rename("P3: fixed top-level source to escaping destination parent",
"fixed-top-to-escape", "escape_link/fixed-created", 0);
check_exists("P3 source preserved", "fixed-top-to-escape", 1);
check_exists("P3 outside destination absent", "../trap/fixed-created", 0);
check_rename("P4: fixed escaping source parent to top-level destination",
"escape_link/fixed-outside-source", "fixed-stolen", 0);
check_exists("P4 outside source preserved", "../trap/fixed-outside-source", 1);
check_exists("P4 destination absent", "fixed-stolen", 0);
if (errs)
fprintf(stderr, "%d failure(s)\n", errs);
return errs ? 1 : 0;
#endif
}
int main(int argc, char **argv)
{
if (argc == 3 && strcmp(argv[1], "--poc") == 0)
return run_escape_poc(argv[2]);
if (argc != 2) {
fprintf(stderr, "usage: %s [--poc] <module-dir>\n", argv[0]);
return 2;
}
#ifndef AT_FDCWD
fprintf(stderr, "SKIP: AT_FDCWD not available\n");
return 77;
#else
if (chdir(argv[1]) < 0) {
perror("chdir");
return 2;
}
am_daemon = 1;
am_chrooted = 0;
/* Plain mixed paths must keep working. */
check_rename("A: top-level source to slashed destination",
"top-to-dir", "realdir/top-to-dir", 1);
check_exists("A source consumed", "top-to-dir", 0);
check_exists("A destination created", "realdir/top-to-dir", 1);
check_rename("B: slashed source to top-level destination",
"realdir/dir-to-top", "dir-to-top", 1);
check_exists("B source consumed", "realdir/dir-to-top", 0);
check_exists("B destination created", "dir-to-top", 1);
/* A slashed destination parent that escapes the module must be rejected. */
check_rename("C: top-level source to escaping destination parent",
"top-to-escape", "escape_link/new-outside", 0);
check_exists("C source preserved", "top-to-escape", 1);
check_exists("C outside destination absent", "../trap/new-outside", 0);
/* A slashed source parent that escapes the module must be rejected too. */
check_rename("D: escaping source parent to top-level destination",
"escape_link/outside-source", "stolen-from-outside", 0);
check_exists("D outside source preserved", "../trap/outside-source", 1);
check_exists("D destination absent", "stolen-from-outside", 0);
check_rename("E: shared slashed parent",
"realdir/same-old", "realdir/same-new", 1);
check_exists("E source consumed", "realdir/same-old", 0);
check_exists("E destination created", "realdir/same-new", 1);
check_rename("F: top-level source to top-level destination",
"top-old", "top-new", 1);
check_exists("F source consumed", "top-old", 0);
check_exists("F destination created", "top-new", 1);
if (errs)
fprintf(stderr, "%d failure(s)\n", errs);
return errs ? 1 : 0;
#endif
}
+8 -1
View File
@@ -25,6 +25,7 @@ int do_fsync = 0;
int inplace = 0;
int am_daemon = 0;
int am_chrooted = 0;
int insecure_links = 0;
int modify_window = 0;
int preallocate_files = 0;
int sparse_files = 0;
@@ -45,6 +46,7 @@ size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained;
* hits at its first my_strdup() call. */
char *partial_dir;
char *module_dir;
int module_dirfd = -1;
/* curr_dir[]/curr_dir_len (read by secure_relative_open) are defined in
* syscall.c, which every helper links -- no stub needed here. */
filter_rule_list daemon_filter_list;
@@ -82,7 +84,7 @@ filter_rule_list daemon_filter_list;
return 0;
}
int copy_xattrs(UNUSED(const char *source), UNUSED(const char *dest))
int copy_xattrs(UNUSED(const char *source), UNUSED(const char *dest), UNUSED(int dest_fd))
{
return -1;
}
@@ -107,6 +109,11 @@ filter_rule_list daemon_filter_list;
return 0;
}
BOOL lp_insecure_links(UNUSED(int mod))
{
return 0;
}
const char *who_am_i(void)
{
return "tester";
+144
View File
@@ -0,0 +1,144 @@
/*
* Test harness for the fake-super branches of do_symlink_at()/do_mknod_at().
* Fake-super stores a symlink/device as a placeholder file, so the create
* resolves the final component; the no-slash branch used to fall back to
* do_symlink()/do_mknod(), whose plain open() followed a planted basename
* symlink and escaped the module. Checks the fixed wrappers refuse it;
* --poc shows the old fallback escaping. Not linked into rsync. GPL version 2.
*/
#include "rsync.h"
#include <sys/stat.h>
/* The symlink placeholder (and thus this escape) exists only where symlink
* xattrs are unavailable -- the same guard do_symlink() uses. Elsewhere
* symlink() fails EEXIST on a planted link, so only the device path applies. */
#if defined SUPPORT_LINKS && (defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS)
#define TEST_SYMLINK_PLACEHOLDER 1
#endif
int dry_run = 0;
int am_root = -1; /* --fake-super */
int am_sender = 0;
int read_only = 0;
int list_only = 0;
int copy_links = 0;
int copy_unsafe_links = 0;
extern int am_daemon, am_chrooted;
short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];
static int errs = 0;
static void check_preserved(const char *label, const char *victim, const char *want)
{
char buf[256];
int fd = open(victim, O_RDONLY);
ssize_t n = fd >= 0 ? read(fd, buf, sizeof buf - 1) : -1;
if (fd >= 0)
close(fd);
if (n < 0)
n = 0;
buf[n] = '\0';
if (n > 0 && buf[n-1] == '\n')
buf[n-1] = '\0';
if (strcmp(buf, want) != 0) {
fprintf(stderr, "FAIL [%s]: victim %s = \"%s\", expected \"%s\" "
"(basename symlink was followed -> module escape)\n",
label, victim, buf, want);
errs++;
return;
}
fprintf(stderr, "OK [%s]: victim %s preserved\n", label, victim);
}
static void check_clobbered(const char *label, const char *victim, const char *unwanted)
{
char buf[256];
int fd = open(victim, O_RDONLY);
ssize_t n = fd >= 0 ? read(fd, buf, sizeof buf - 1) : -1;
if (fd >= 0)
close(fd);
if (n < 0)
n = 0;
buf[n] = '\0';
if (n > 0 && buf[n-1] == '\n')
buf[n-1] = '\0';
if (strcmp(buf, unwanted) != 0) {
fprintf(stderr, "FAIL [%s]: victim %s = \"%s\", expected the escape to write \"%s\"\n",
label, victim, buf, unwanted);
errs++;
return;
}
fprintf(stderr, "OK [%s]: victim %s clobbered as expected (escape demonstrated)\n",
label, victim);
}
int main(int argc, char **argv)
{
#ifndef AT_FDCWD
fprintf(stderr, "SKIP: AT_FDCWD not available\n");
return 77;
#else
int poc = 0;
const char *moddir;
if (argc == 3 && strcmp(argv[1], "--poc") == 0) {
poc = 1;
moddir = argv[2];
} else if (argc == 2) {
moddir = argv[1];
} else {
fprintf(stderr, "usage: %s [--poc] <module-dir>\n", argv[0]);
return 2;
}
if (chdir(moddir) < 0) {
perror("chdir");
return 2;
}
am_daemon = 1;
am_chrooted = 0;
am_root = -1; /* fake-super: symlinks/devices stored as files */
if (poc) {
/* Pre-fix fallback: a no-slash path went to do_symlink()/do_mknod(),
* which open() the basename without O_NOFOLLOW. */
#ifdef TEST_SYMLINK_PLACEHOLDER
do_symlink("VULN_SYM_PAYLOAD", "sympath");
check_clobbered("poc do_symlink bare", "../outside/secret_sym",
"VULN_SYM_PAYLOAD");
#endif
do_mknod("nodpath", S_IFCHR | 0600, 0);
check_clobbered("poc do_mknod bare", "../outside/secret_nod", "");
return errs ? 1 : 0;
}
/* Fixed wrappers: a bare-path basename symlink must not be followed;
* the victim outside the module stays untouched. */
#ifdef TEST_SYMLINK_PLACEHOLDER
do_symlink_at("FIXED_SYM_PAYLOAD", "sympath");
check_preserved("do_symlink_at bare", "../outside/secret_sym", "VICTIM_SYM");
/* Slashed path for parity (already protected before the fix). */
do_symlink_at("FIXED_SYM_PAYLOAD", "sub/sympath2");
check_preserved("do_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2");
#endif
do_mknod_at("nodpath", S_IFCHR | 0600, 0);
check_preserved("do_mknod_at bare", "../outside/secret_nod", "VICTIM_NOD");
do_mknod_at("sub/nodpath2", S_IFCHR | 0600, 0);
check_preserved("do_mknod_at slashed", "../outside/secret_nod2", "VICTIM_NOD2");
if (errs)
fprintf(stderr, "%d failure(s)\n", errs);
return errs ? 1 : 0;
#endif
}
+1 -1
View File
@@ -90,7 +90,7 @@ Status legend: ✓ property asserted · `~` shallow / by an existing ported test
| -x, --one-file-system | — | — | — | ✗ (needs a mount boundary) |
| --preallocate / --fsync | — | — | — | ✗ |
| -B, --block-size | — | — | — | ✗ |
| --max-alloc | — | — | — | ✗ |
| --max-alloc | max-alloc-zero-rejected*new*, daemon-max-alloc-zero*new* | — | — | ✓ zero rejected locally and when forwarded to the daemon (needs an old client) |
### Filtering
| option | test(s) | depth | x-dir | notes / gap |
+33
View File
@@ -9,6 +9,39 @@ is gone). Shared helpers live in `testsuite/rsyncfns.py`. A handful of C helper
programs (`tls`, `getgroups`, `trimslash`, …) are built alongside `rsync` and
used by some tests. Coverage notes are in [COVERAGE.md](COVERAGE.md).
## Writing tests
Favour readability — a test is also documentation of the behaviour it pins, so
prefer clarity over cleverness:
* When a test writes an `rsyncd.conf`, write it as a triple-quoted f-string so
the actual config is readable top-to-bottom, with module parameters indented
with plain spaces. Don't build it from adjacent string literals full of `\n`
(and `\t`) escapes. The daemon's parser accepts space-indented parameters.
* Better still, use the structured helpers in `rsyncfns.py` when a stock config
will do: `write_daemon_conf(modules, globals)` (per-test modules/params) or
`build_rsyncd_conf()` (the four standard modules). They also handle the
root-only `uid`/`gid` lines for you (needed so a `use chroot = no` daemon run
as root can read a root-owned module).
* For config that varies (e.g. those root-only `uid`/`gid` lines), interpolate a
single optional block that expands when needed and is an empty string
otherwise, rather than splicing pieces together:
```python
root = get_testuid() == get_rootuid()
ids = f"uid = {get_rootuid()}\ngid = {get_rootgid()}" if root else ""
conf.write_text(f"""\
pid file = {base}/rsyncd.pid
use chroot = no
{ids}
log file = {base}/rsyncd.log
[m]
path = {mod}
read only = yes
""")
```
## Running the tests
### Via make
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Compare two directory trees and print any differences.
Reuses rsyncfns.compare_trees(), so it is special-file aware (never
opens a fifo/socket/device as a stream) and reports differences in: the tls
listing (type, mode, owner, size, mtime, symlink target for every inode),
regular-file contents, user xattrs, POSIX ACLs, and hard-link grouping.
Works on any two trees, not just ones built by mkvariety.py. Exit status is 0
when the trees match, 1 when they differ.
Examples:
testsuite/cmptree.py /tmp/vt/transfer_root /tmp/copy
testsuite/cmptree.py --no-acls --no-xattrs treeA treeB
"""
import argparse
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
def _resolve_tooldir(opt):
"""Directory holding the `tls` helper (needed for the listing comparison)."""
for d in (opt, os.path.dirname(_HERE), os.getcwd()):
if d and os.path.exists(os.path.join(d, 'tls')):
return d
return None
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('tree_a')
ap.add_argument('tree_b')
ap.add_argument('--no-xattrs', dest='xattrs', action='store_false',
help='skip user-xattr comparison')
ap.add_argument('--no-acls', dest='acls', action='store_false',
help='skip POSIX ACL comparison')
ap.add_argument('--tooldir', default=None,
help='directory holding the tls helper '
'(default: the build tree, else cwd)')
ap.add_argument('-q', '--quiet', action='store_true',
help='print nothing; only set the exit status')
args = ap.parse_args()
for t in (args.tree_a, args.tree_b):
if not os.path.isdir(t):
ap.error(f"not a directory: {t}")
tooldir = _resolve_tooldir(args.tooldir)
if tooldir is None:
ap.error("cannot find the 'tls' helper (needed for the listing "
"comparison); build it with `make check-progs` or pass "
"--tooldir DIR")
# rsyncfns reads these at import time; compare_trees only needs
# TOOLDIR (for tls). scratchdir must be an existing dir but is unused here.
import tempfile
os.environ.setdefault('scratchdir', tempfile.gettempdir())
os.environ.setdefault('srcdir', os.getcwd())
os.environ.setdefault('TOOLDIR', tooldir)
os.environ.setdefault('RSYNC', 'rsync')
sys.path.insert(0, _HERE)
import rsyncfns as R
diffs = R.compare_trees(args.tree_a, args.tree_b,
with_acls=args.acls, with_xattrs=args.xattrs)
if diffs:
if not args.quiet:
print(f"trees DIFFER ({len(diffs)} difference(s)):")
print('\n'.join(diffs))
return 1
if not args.quiet:
print("trees are identical")
return 0
if __name__ == '__main__':
sys.exit(main())
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Generate a "variety tree" for manual inspection and ad-hoc rsync testing.
Builds exactly the tree that rsyncfns.make_variety_tree() produces for the
regression suite (every inode type rsync handles, with heavy symlink, perm,
xattr, ACL, hard-link and -- as root -- ownership coverage), so you can drive
rsync at it by hand. Transfer the transfer_root/ subdir; the escape/ links
deliberately point up into the sibling above/ tree.
Examples:
testsuite/mkvariety.py /tmp/vt
testsuite/mkvariety.py /tmp/vt --list
sudo testsuite/mkvariety.py /tmp/vt # adds device nodes + owners
testsuite/mkvariety.py /tmp/vt --no-acls --depth 4
rsync -aHAX --specials --devices /tmp/vt/transfer_root/ /tmp/copy/
rsync -a --safe-links /tmp/vt/transfer_root/ /tmp/copy2/
"""
import argparse
import os
import stat
import sys
from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
def _resolve_rsync(opt):
"""Find an rsync binary to probe xattr/ACL capability with."""
if opt:
return os.path.abspath(opt)
cand = os.path.join(os.path.dirname(_HERE), 'rsync') # build-tree ./rsync
if os.path.exists(cand):
return cand
from shutil import which
return which('rsync') or 'rsync'
def _detect(flag, prober, default):
if flag is not None:
return flag
try:
return prober()
except Exception:
return default
def list_tree(root):
rootp = Path(root)
for dp, dns, fns in os.walk(root): # followlinks=False
dns.sort()
for name in sorted(dns + fns):
p = Path(dp) / name
m = p.lstat().st_mode
tgt = ' -> ' + os.readlink(p) if stat.S_ISLNK(m) else ''
print(f"{stat.filemode(m)} {p.relative_to(rootp)}{tgt}")
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('target', help='directory to create (REMOVED first if it exists)')
ap.add_argument('--depth', type=int, default=8, help='backbone depth (default 8)')
ap.add_argument('--seed', default='0x5A17',
help='int seed for the fixed choices (default 0x5A17)')
ap.add_argument('--rsync', default=None,
help='rsync binary for xattr/ACL capability probes '
'(default: ./rsync in the build tree, else PATH rsync)')
ap.add_argument('--list', action='store_true',
help='print a recursive listing of the tree afterwards')
for cap, helptext in (('xattrs', 'user xattrs'), ('acls', 'POSIX ACLs'),
('devices', 'char/block device nodes (needs root)'),
('owners', 'mixed uid/gid (needs root)')):
g = ap.add_mutually_exclusive_group()
g.add_argument(f'--{cap}', dest=cap, action='store_true', default=None,
help=f'force {helptext} on')
g.add_argument(f'--no-{cap}', dest=cap, action='store_false',
help=f'force {helptext} off')
args = ap.parse_args()
target = Path(args.target).resolve()
for bad in (Path('/'), Path.home(), Path.cwd()):
if target == bad:
ap.error(f"refusing to use {target} as the target (it is wiped first)")
rsync = _resolve_rsync(args.rsync)
# rsyncfns reads these at import time; set them before importing it.
os.environ.setdefault('scratchdir', str(target.parent))
os.environ.setdefault('srcdir', os.getcwd())
os.environ.setdefault('TOOLDIR', os.path.dirname(rsync) or os.getcwd())
os.environ.setdefault('RSYNC', rsync)
sys.path.insert(0, _HERE)
import rsyncfns as R
caps = dict(
with_xattrs=_detect(args.xattrs, R.xattrs_supported, False),
with_acls=_detect(args.acls, R.acls_supported, False),
with_devices=args.devices if args.devices is not None
else R.devices_supported(),
with_owners=args.owners if args.owners is not None
else R.owners_supported(),
)
info = R.make_variety_tree(target, depth=args.depth,
seed=int(args.seed, 0), **caps)
tr = info['transfer_root']
print(f"created {sum(info['counts'].values())} entries under {target}")
print(f" counts: {info['counts']}")
print(f" caps: xattrs={caps['with_xattrs']} acls={caps['with_acls']} "
f"devices={caps['with_devices']} owners={caps['with_owners']}")
if not caps['with_devices'] or not caps['with_owners']:
print(" (run as root to add device nodes and mixed ownership)")
print(f"\ntransfer this: {tr}/")
print(f"e.g. rsync -aHAX --specials --devices {tr}/ /tmp/copy/")
if args.list:
print()
list_tree(target)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
+1109 -55
View File
File diff suppressed because it is too large. Load diff
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""xrsync.py -- a small, runnable rsync client built on rsync_proto.py.
It speaks the rsync daemon protocol (host::module/path or rsync://host/module/
path) and supports three operations against a real rsyncd:
xrsync.py [opts] host::module/path # list (like rsync's listing)
xrsync.py [opts] host::module/glob localdir # pull (download)
xrsync.py [opts] localfile... host::mod/ # push (upload, regular files)
This is deliberately a *subset* of rsync -- enough to be useful for protocol
development and as a test harness, not a drop-in replacement. Supported flags:
-a (= -rlpt), -r, -l, -t, -p, -v, --list-only, --port=N.
Hookability: rsync_proto's DaemonClient exposes the protocol steps as small
overridable methods (recv_flist / make_request / recv_file_transfer /
make_file_token_stream / ...). main() takes a `client_factory`, so a test can
pass a DaemonClient subclass that tampers with one step while reusing all of
xrsync's machinery. See xrsync_test.py.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import rsync_proto as rp # noqa: E402
DEFAULT_PORT = 873
CAPS = 'e.LsfxCIu' # the -e capability marker rsync_proto negotiates at p30
def parse_remote(spec):
"""Return (host, port, path) for a daemon spec, or None if `spec` is local.
`path` keeps the module as its first component (what the daemon expects)."""
if spec.startswith('rsync://'):
rest = spec[len('rsync://'):]
hostport, _, path = rest.partition('/')
host, _, port = hostport.partition(':')
return host, int(port) if port else DEFAULT_PORT, path
if '::' in spec:
host, _, path = spec.partition('::')
return host, None, path
return None
def server_opts(args, listing):
"""Build the daemon-side short-option string from the parsed flags."""
flags = ''
if args.recurse:
flags += 'r'
if args.links:
flags += 'l'
if args.perms:
flags += 'p'
if args.times:
flags += 't'
if listing and not args.recurse:
flags += 'd' # list a directory's contents without recursing
return '-' + flags + CAPS
def do_list(host, port, module, path, args, factory):
c = factory(host, port)
c.handshake(module, ['--server', '--sender', server_opts(args, True),
'.', path], greeting_version=30)
entries = rp.sort_entries(c.recv_flist(preserve_links=args.links))
for e in entries:
tgt = ''
if e.is_link and e.link_target is not None:
tgt = ' -> ' + e.link_target.decode('utf-8', 'surrogateescape')
print('%s %15d %s%s' % (rp.mode_to_perms(e.mode), e.length,
e.name.decode('utf-8', 'surrogateescape'), tgt))
c.finish_no_transfer()
c.drain(timeout=1.0)
c.close()
return 0
def do_pull(host, port, module, path, dest, args, factory):
c = factory(host, port)
c.handshake(module, ['--server', '--sender', server_opts(args, False),
'.', path], greeting_version=30)
c.pull(dest, verbose=args.verbose, preserve_times=args.times,
preserve_perms=args.perms)
c.drain(timeout=1.0)
c.close()
return 0
def do_push(srcs, host, port, module, path, args, factory):
files = []
for s in srcs:
if os.path.isfile(s):
with open(s, 'rb') as fh:
files.append((os.path.basename(s), fh.read()))
elif os.path.isdir(s):
for root, _dirs, names in os.walk(s):
for nm in names:
full = os.path.join(root, nm)
if os.path.isfile(full) and not os.path.islink(full):
rel = os.path.relpath(full, s)
with open(full, 'rb') as fh:
files.append((rel, fh.read()))
c = factory(host, port)
c.handshake(module, ['--server', server_opts(args, False), '.', path],
greeting_version=30)
c.push(files)
if args.verbose:
for name, _ in files:
print(name)
c.drain(timeout=1.0)
c.close()
return 0
def main(argv=None, client_factory=rp.DaemonClient):
p = argparse.ArgumentParser(prog='xrsync.py', add_help=True)
p.add_argument('-a', '--archive', action='store_true', help='= -rlpt')
p.add_argument('-r', '--recursive', dest='recurse', action='store_true')
p.add_argument('-l', '--links', action='store_true')
p.add_argument('-p', '--perms', action='store_true')
p.add_argument('-t', '--times', action='store_true')
p.add_argument('-v', '--verbose', action='store_true')
p.add_argument('--list-only', action='store_true')
p.add_argument('--port', type=int, default=None)
p.add_argument('paths', nargs='+')
args = p.parse_args(argv)
if args.archive:
args.recurse = args.links = args.perms = args.times = True
paths = args.paths
remotes = [parse_remote(x) for x in paths]
# List: a single remote arg (or --list-only with no local dest).
if args.list_only or (len(paths) == 1 and remotes[0] is not None):
host, port, path = remotes[0]
port = args.port or port or DEFAULT_PORT
module = path.split('/')[0]
return do_list(host, port, module, path, args, client_factory)
if len(paths) < 2:
p.error('need a source and a destination')
*srcs, dest = paths
*src_remotes, dest_remote = remotes
if dest_remote is not None and all(r is None for r in src_remotes):
host, port, path = dest_remote
port = args.port or port or DEFAULT_PORT
module = path.split('/')[0]
return do_push(srcs, host, port, module, path, args, client_factory)
if len(srcs) == 1 and src_remotes[0] is not None and dest_remote is None:
host, port, path = src_remotes[0]
port = args.port or port or DEFAULT_PORT
module = path.split('/')[0]
return do_pull(host, port, module, path, dest, args, client_factory)
p.error('unsupported source/destination combination (one side must be '
'host::module/path and the other local)')
if __name__ == '__main__':
sys.exit(main())