mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-12 21:28:25 -04:00
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:
1 parent
88cee08963
commit
1346bc623f
13 files changed
+3566
-136
No files matched your search
+50
-12
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+82
@@ -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())
|
||||
Executable
+126
@@ -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
File diff suppressed because it is too large.
Load diff
Executable
+164
@@ -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())
|
||||
Reference in new issue
Block a user