diff --git a/NEWS.md b/NEWS.md index c4f5aeaf8..ccb51b80d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -7,6 +7,15 @@ on macOS. Native configuration now proves lookup, creation, permission-revocation and no-follow behaviour before selecting a traversal descriptor. Unverified targets fail closed instead of using raceable paths. +- Rejected config and filter files after read or stream setup errors instead + of accepting partial contents. MOTD and `--early-input` reads now stop on + I/O errors instead of looping; interrupted reads retain their data. +- Fixed an out-of-bounds read while reporting an incomplete daemon config + section header. +- Preserved sender file-open errors when closing a held directory descriptor + during `--copy-links` transfers. +- Changed to `/` immediately after a module chroot, before selecting an inner + module directory. A failed working-directory reset aborts the connection. # NEWS for rsync 3.5.2 (4 Nov 2026) diff --git a/clientserver.c b/clientserver.c index 586cc05bf..eafd231fd 100644 --- a/clientserver.c +++ b/clientserver.c @@ -177,6 +177,21 @@ int start_socket_client(char *host, int remote_argc, char *remote_argv[], return ret ? ret : client_run(fd, fd, -1, argc, argv); } +static size_t fread_retry(void *buffer, size_t length, FILE *file) +{ + size_t total = fread(buffer, 1, length, file); + while (total < length && ferror(file) && errno == EINTR) { + clearerr(file); + /* Preserve partial input across EINTR. Clang 21 does not model + * the recoverable position after an interrupted stream read. */ +#ifdef __clang_analyzer__ + __attribute__((suppress)) +#endif + total += fread((char *)buffer + total, 1, length - total, file); + } + return total; +} + static int exchange_protocols(int f_in, int f_out, char *buf, size_t bufsiz, int am_client) { int remote_sub = -1; @@ -191,9 +206,20 @@ static int exchange_protocols(int f_in, int f_out, char *buf, size_t bufsiz, int * symlinks not owned by uid 0 or our euid. */ int motd_fd = open_no_attacker_symlinks(motd, O_RDONLY, 0); FILE *f = motd_fd >= 0 ? fdopen(motd_fd, "r") : NULL; - if (!f && motd_fd >= 0) close(motd_fd); + if (!f && motd_fd >= 0) { + int fdopen_errno = errno; + close(motd_fd); + rsyserr(FLOG, fdopen_errno, "failed to open motd file %s", motd); + return -1; + } while (f && !feof(f)) { - int len = fread(buf, 1, bufsiz - 1, f); + int len = fread_retry(buf, bufsiz - 1, f); + if (ferror(f)) { + int read_errno = errno; + fclose(f); + rsyserr(FLOG, read_errno, "failed to read motd file %s", motd); + return -1; + } if (len > 0) write_buf(f_out, buf, len); } @@ -306,7 +332,11 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char * our euid anywhere in the path. */ int ei_fd = open_no_attacker_symlinks(early_input_file, O_RDONLY, 0); FILE *f = ei_fd >= 0 ? fdopen(ei_fd, "rb") : NULL; - if (!f && ei_fd >= 0) close(ei_fd); + if (!f && ei_fd >= 0) { + int fdopen_errno = errno; + close(ei_fd); + errno = fdopen_errno; + } if (!f || do_fstat(fileno(f), &st) < 0) { rsyserr(FERROR, errno, "failed to open %s", early_input_file); if (f) @@ -331,7 +361,14 @@ int start_inband_exchange(int f_in, int f_out, const char *user, int argc, char free(modname); return -1; } - len = fread(line, 1, early_input_len, f); + len = fread_retry(line, early_input_len, f); + if (ferror(f)) { + int read_errno = errno; + fclose(f); + free(modname); + rsyserr(FERROR, read_errno, "failed to read early-input file %s", early_input_file); + return -1; + } if (len > 0) { write_buf(f_out, line, len); early_input_len -= len; @@ -1056,6 +1093,8 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char io_printf(f_out, "@ERROR: chroot failed\n"); return -1; } + if (chdir("/") < 0) + return path_failure(f_out, "/", True); am_chrooted = 1; module_chdir = module_dir; } diff --git a/exclude.c b/exclude.c index 7476f6a87..47ae8c143 100644 --- a/exclude.c +++ b/exclude.c @@ -1598,6 +1598,21 @@ void parse_filter_str(filter_rule_list *listp, const char *rulestr, } } +static int get_filter_char(FILE *file) +{ + int c = getc(file); + while (c == EOF && ferror(file) && errno == EINTR) { + clearerr(file); + /* EINTR leaves the unread byte in place, including on pipes. + * Clang 21's stream model does not recognise this recovery. */ +#ifdef __clang_analyzer__ + __attribute__((suppress)) +#endif + c = getc(file); + } + return c; +} + void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_rule *template, int xflags) { FILE *fp; @@ -1685,8 +1700,11 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ if (fd < 0) fp = NULL; - else if (!(fp = fdopen(fd, "rb"))) + else if (!(fp = fdopen(fd, "rb"))) { + int fdopen_errno = errno; close(fd); + errno = fdopen_errno; + } } else fp = stdin; @@ -1762,11 +1780,7 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ if (pending != EOF) { /* a CR lookahead we could not push back */ ch = pending; pending = EOF; - } else if ((ch = getc(fp)) == EOF) { - if (ferror(fp) && errno == EINTR) { - clearerr(fp); - continue; - } + } else if ((ch = get_filter_char(fp)) == EOF) { break; } if (word_split && isspace(ch)) @@ -1774,9 +1788,7 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ if (eol_nulls? !ch : (ch == '\n' || ch == '\r')) { if (ch == '\r') { /* CRLF is one line, not two */ int nxt; - while ((nxt = getc(fp)) == EOF - && ferror(fp) && errno == EINTR) - clearerr(fp); + nxt = get_filter_char(fp); if (nxt == EOF) { if (!ferror(fp)) ch = EOF; /* real end of file */ @@ -1796,6 +1808,14 @@ void parse_filter_file(filter_rule_list *listp, const char *fname, const filter_ else overflow = 1; } + if (ferror(fp)) { + if (named_by_file) + rprintf(FERROR, "failed to read filter file %s\n", rule_text(template, src_name)); + else + rsyserr(FERROR, errno, "failed to read filter file %s", src_name); + fclose(fp); + exit_cleanup(RERR_FILEIO); + } if (overflow) { rprintf(FERROR, "discarding over-long filter: %s\n", rule_text_len(NULL, line, 0)); diff --git a/params.c b/params.c index 933db7cb1..20d56d2bc 100644 --- a/params.c +++ b/params.c @@ -101,6 +101,16 @@ static BOOL (*the_pfunc)(char *, char *); * Functions... */ +static int get_char(FILE *file) +{ + int c; + if (feof(file) || ferror(file)) + return EOF; + while ((c = getc(file)) == EOF && ferror(file) && errno == EINTR) + clearerr(file); + return c; +} + static int EatWhitespace( FILE *InFile ) /* ------------------------------------------------------------------------ ** * Scan past whitespace (see ctype(3C)) and return the first non-whitespace @@ -121,7 +131,7 @@ static int EatWhitespace( FILE *InFile ) { int c; - for( c = getc( InFile ); isspace( c ) && ('\n' != c); c = getc( InFile ) ) + for( c = get_char( InFile ); isspace( c ) && ('\n' != c); c = get_char( InFile ) ) ; return( c ); } /* EatWhitespace */ @@ -146,7 +156,7 @@ static int EatComment( FILE *InFile ) { int c; - for( c = getc( InFile ); ('\n'!=c) && (EOF!=c) && (c>0); c = getc( InFile ) ) + for( c = get_char( InFile ); ('\n'!=c) && (EOF!=c) && (c>0); c = get_char( InFile ) ) ; return( c ); } /* EatComment */ @@ -239,7 +249,7 @@ static BOOL Section( FILE *InFile, BOOL (*sfunc)(char *) ) return( False ); } end = ( (i > 0) && (' ' == bufr[i - 1]) ) ? (i - 1) : (i); - c = getc( InFile ); /* Continue with next line. */ + c = get_char( InFile ); /* Continue with next line. */ break; default: /* All else are a valid name chars. */ @@ -253,12 +263,15 @@ static BOOL Section( FILE *InFile, BOOL (*sfunc)(char *) ) { bufr[i++] = c; end = i; - c = getc( InFile ); + c = get_char( InFile ); } } } /* We arrive here if we've met the EOF before the closing bracket. */ + if (ferror(InFile)) + return False; + bufr[end] = '\0'; rprintf(FLOG, "%s Unexpected EOF in the config file: %s\n", func, bufr ); return( False ); } /* Section */ @@ -326,11 +339,13 @@ static BOOL Parameter( FILE *InFile, BOOL (*pfunc)(char *, char *), int c ) return( True ); } end = ( (i > 0) && (' ' == bufr[i - 1]) ) ? (i - 1) : (i); - c = getc( InFile ); /* Read past eoln. */ + c = get_char( InFile ); /* Read past eoln. */ break; case '\0': /* Shouldn't have EOF within param name. */ case EOF: + if (ferror(InFile)) + return False; bufr[i] = '\0'; rprintf(FLOG, "%s Unexpected end-of-file at: %s\n", func, bufr ); return( True ); @@ -359,7 +374,7 @@ static BOOL Parameter( FILE *InFile, BOOL (*pfunc)(char *, char *), int c ) { bufr[i++] = c; end = i; - c = getc( InFile ); + c = get_char( InFile ); } } } @@ -377,7 +392,7 @@ static BOOL Parameter( FILE *InFile, BOOL (*pfunc)(char *, char *), int c ) switch( c ) { case '\r': /* Explicitly remove '\r' because the older */ - c = getc( InFile ); /* version called fgets_slash() which also */ + c = get_char( InFile ); /* version called fgets_slash() which also */ break; /* removes them. */ case '\n': /* Marks end of value unless there's a '\'. */ @@ -388,7 +403,7 @@ static BOOL Parameter( FILE *InFile, BOOL (*pfunc)(char *, char *), int c ) { for( end = i; end >= 0 && isSpace(bufr + end); end-- ) ; - c = getc( InFile ); + c = get_char( InFile ); } break; @@ -396,12 +411,14 @@ static BOOL Parameter( FILE *InFile, BOOL (*pfunc)(char *, char *), int c ) bufr[i++] = c; /* not advance . This allows trimming */ if( !isspace( c ) ) /* of whitespace at the end of the line. */ end = i; - c = getc( InFile ); + c = get_char( InFile ); break; } } bufr[end] = '\0'; /* End of value. */ + if (ferror(InFile)) + return False; return( pfunc( bufr, &bufr[vstart] ) ); /* Pass name & value to pfunc(). */ } /* Parameter */ @@ -586,7 +603,11 @@ static FILE *OpenConfFile( char *FileName ) int cfg_fd = open_no_attacker_symlinks( FileName, O_RDONLY, 0 ); OpenedFile = cfg_fd >= 0 ? fdopen( cfg_fd, "r" ) : NULL; if( !OpenedFile && cfg_fd >= 0 ) + { + int fdopen_errno = errno; close( cfg_fd ); + errno = fdopen_errno; + } } if( NULL == OpenedFile ) { @@ -617,21 +638,26 @@ int pm_process( char *FileName, { int result; FILE *InFile; + BOOL own_buffer = bufr == NULL; char *func = "params.c:pm_process() -"; InFile = OpenConfFile( FileName ); /* Open the config file. */ if( NULL == InFile ) return( False ); - if( NULL != bufr ) /* If we already have a buffer */ - result = Parse( InFile, sfunc, pfunc ); /* (recursive call), then just */ - /* use it. */ - - else /* If we don't have a buffer */ - { /* allocate one, then parse, */ - bSize = BUFR_INC; /* then free. */ + if (own_buffer) + { + bSize = BUFR_INC; bufr = new_array( char, bSize ); - result = Parse( InFile, sfunc, pfunc ); + } + result = Parse( InFile, sfunc, pfunc ); + if (ferror(InFile)) + { + rsyserr(FLOG, errno, "failed to read config file \"%s\"", FileName); + result = 0; + } + if (own_buffer) + { free( bufr ); bufr = NULL; bSize = 0; diff --git a/sender.c b/sender.c index c1b903960..c0ef084d3 100644 --- a/sender.c +++ b/sender.c @@ -313,11 +313,13 @@ static int sender_open_copylinks_confined(const char *anchor, const char *relpat if (n < 0) { /* EINVAL: not a symlink -> the resolved target file. Open it * O_NOFOLLOW (a raced symlink flip is still refused). */ - fd = e == EINVAL - ? openat(pdfd, bname, O_RDONLY | O_NOFOLLOW | O_BINARY | extra, 0) - : -1; + if (e == EINVAL) { + fd = openat(pdfd, bname, O_RDONLY | O_NOFOLLOW | O_BINARY | extra, 0); + e = fd < 0 ? errno : 0; + } else + fd = -1; close(pdfd); - if (n < 0 && e != EINVAL) + if (fd < 0) errno = e; return fd; } diff --git a/testsuite/chroot-cwd.c b/testsuite/chroot-cwd.c new file mode 100644 index 000000000..692304066 --- /dev/null +++ b/testsuite/chroot-cwd.c @@ -0,0 +1,40 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static int jailed, marker = -1; + +int chroot(const char *path) +{ + int (*original)(const char *) = dlsym(RTLD_NEXT, "chroot"); + const char *target = getenv("RSYNC_TEST_CHROOT_PATH"); + int selected = target && strcmp(path, target) == 0; + int result; + if (selected) + marker = open(getenv("RSYNC_TEST_CHROOT_MARKER"), O_WRONLY | O_CREAT | O_APPEND, 0600); + result = original(path); + if (selected && result == 0) + jailed = 1; + return result; +} + +int chdir(const char *path) +{ + int (*original)(const char *) = dlsym(RTLD_NEXT, "chdir"); + if (jailed) { + jailed = 0; + if (marker >= 0) { + (void)write(marker, path, strlen(path)); + (void)write(marker, "\n", 1); + } + if (getenv("RSYNC_TEST_CHROOT_DENY") && strcmp(path, "/") == 0) { + errno = EACCES; + return -1; + } + } + return original(path); +} diff --git a/testsuite/copy-links-open-error_test.py b/testsuite/copy-links-open-error_test.py new file mode 100644 index 000000000..804ba63c9 --- /dev/null +++ b/testsuite/copy-links-open-error_test.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import errno +import os +import platform +import subprocess + +from rsyncfns import FROMDIR, SCRATCHDIR, SRCDIR, forced_protocol, rsync_argv, start_test_daemon, test_fail, test_skipped + + +def test(): + if platform.system() != 'Linux' or os.environ.get('ASAN_OPTIONS'): + test_skipped('open fault injection needs Linux without ASan') + library = SCRATCHDIR / 'sender-open-error.so' + fixture = SRCDIR / 'testsuite/sender-open-error.c' + build = subprocess.run(['cc', '-shared', '-fPIC', '-o', str(library), str(fixture), '-ldl'], capture_output=True, text=True) + if build.returncode: + test_fail(f'cannot build open hook: {build.stderr}') + FROMDIR.mkdir() + (FROMDIR / 'file').write_text('file data\n') + config = SCRATCHDIR / 'rsyncd.conf' + identity = f'uid = {os.getuid()}\ngid = {os.getgid()}\n' if os.geteuid() == 0 else '' + config.write_text(f'use chroot = no\n[files]\npath = {FROMDIR}\n' + identity) + url = start_test_daemon(config, 12919) + marker = SCRATCHDIR / 'open-error' + for error, expected in ((0, 0), (errno.ENOENT, 24), (errno.EACCES, 23)): + if error == errno.ENOENT and (forced_protocol() or 33) < 30: + expected = 0 + marker.unlink(missing_ok=True) + destination = SCRATCHDIR / f'destination-{error}' + env = {**os.environ, 'LD_PRELOAD': str(library), 'LC_ALL': 'C', 'RSYNC_TEST_OPEN_FILE': 'file', + 'RSYNC_TEST_OPEN_ERRNO': str(error), 'RSYNC_TEST_OPEN_MARKER': str(marker)} + result = subprocess.run(rsync_argv('-rL', f'{url}files/', f'{destination}/'), env=env, + capture_output=True, text=True, timeout=15) + if result.returncode != expected: + test_fail(f'open errno {error}: expected exit {expected}, got {result.returncode}: {result.stderr}') + if error and not marker.exists(): + test_fail('open hook did not fire') + if error and (destination / 'file').exists(): + test_fail('failed open produced a destination file') + if error == errno.EACCES and os.strerror(error) not in result.stderr: + test_fail(f'open error was overwritten: {result.stderr}') + if not error and (destination / 'file').read_text() != 'file data\n': + test_fail('open control changed file data') + + +if __name__ == '__main__': + test() diff --git a/testsuite/daemon-chroot-cwd_test.py b/testsuite/daemon-chroot-cwd_test.py new file mode 100644 index 000000000..9e50f53df --- /dev/null +++ b/testsuite/daemon-chroot-cwd_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +import ctypes +import os +import platform +import subprocess + +from rsyncfns import FROMDIR, SCRATCHDIR, SRCDIR, rsync_argv, start_test_daemon, test_fail, test_skipped + + +def test(): + if platform.system() != 'Linux' or os.environ.get('ASAN_OPTIONS'): + test_skipped('chroot fault injection needs Linux without ASan') + if 'valgrind' in rsync_argv()[0]: + test_skipped('Valgrind logs are outside the daemon chroot') + pid = os.fork() + if pid == 0: + os._exit(0 if ctypes.CDLL(None, use_errno=True).chroot(b'/') == 0 else 1) + _, status = os.waitpid(pid, 0) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status): + test_skipped('chroot is unavailable') + library = SCRATCHDIR / 'chroot-cwd.so' + fixture = SRCDIR / 'testsuite/chroot-cwd.c' + build = subprocess.run(['cc', '-shared', '-fPIC', '-o', str(library), str(fixture), '-ldl'], capture_output=True, text=True) + if build.returncode: + test_fail(f'cannot build chroot hook: {build.stderr}') + root = SCRATCHDIR / 'root' + inner = root / 'inner' + inner.mkdir(parents=True) + (root / 'outside-inner').write_text('outside\n') + (inner / 'inside').write_text('inside\n') + config = SCRATCHDIR / 'rsyncd.conf' + marker = SCRATCHDIR / 'chdir' + url = start_test_daemon(config, 12920) + for name, path, denied in (('plain', str(root), False), ('inner', f'{root}/./inner', False), + ('denied', f'{root}/./inner', True)): + marker.unlink(missing_ok=True) + config.write_text(f'use chroot = yes\n[files]\npath = {path}\n') + env = {**os.environ, 'LD_PRELOAD': str(library), 'RSYNC_TEST_CHROOT_PATH': str(root), + 'RSYNC_TEST_CHROOT_MARKER': str(marker)} + if denied: + env['RSYNC_TEST_CHROOT_DENY'] = '1' + destination = SCRATCHDIR / name + result = subprocess.run(rsync_argv('-r', f'{url}files/', f'{destination}/'), env=env, + capture_output=True, text=True, timeout=15) + if not marker.exists() or marker.read_text() != '/\n': + test_fail(f'{name}: did not enter the new root immediately') + if (result.returncode != 0) != denied: + test_fail(f'{name}: unexpected exit {result.returncode}: {result.stderr}') + if name == 'plain' and (destination / 'outside-inner').read_text() != 'outside\n': + test_fail('plain chroot selected the wrong directory') + if name == 'inner' and ((destination / 'inside').read_text() != 'inside\n' + or (destination / 'outside-inner').exists()): + test_fail('inner chroot selected the wrong directory') + + +if __name__ == '__main__': + test() diff --git a/testsuite/daemon-config-truncation_test.py b/testsuite/daemon-config-truncation_test.py new file mode 100644 index 000000000..b0256426f --- /dev/null +++ b/testsuite/daemon-config-truncation_test.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import socket +import subprocess +from rsyncfns import FROMDIR, SCRATCHDIR, rsync_argv, test_fail + + +FROMDIR.mkdir() +config = SCRATCHDIR / 'rsyncd.conf' +valid = f'use chroot = no\n[valid]\npath = {FROMDIR}\n' +for text in (valid, '[', '[unfinished', '[unfinished\n'): + config.write_text(text) + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + listener.listen(1) + with socket.create_connection(listener.getsockname(), timeout=5) as client: + connection, _ = listener.accept() + with connection, subprocess.Popen(rsync_argv('--daemon', '--no-detach', f'--config={config}'), + stdin=connection, stdout=connection, stderr=subprocess.PIPE) as process: + connection.close() + chunks = [] + try: + try: + client.sendall(b'@RSYNCD: 30.0\n\n') + while data := client.recv(4096): + chunks.append(data) + except (BrokenPipeError, ConnectionResetError): + pass + process.wait(timeout=5) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + output = b''.join(chunks) + error = process.stderr.read() + if text == valid: + if process.returncode != 255 or b'valid' not in output: + test_fail(f'valid config failed: {output!r} {error!r}') + elif process.returncode != 1: + test_fail(f'incomplete section: exit {process.returncode}: {output!r} {error!r}') diff --git a/testsuite/sender-open-error.c b/testsuite/sender-open-error.c new file mode 100644 index 000000000..8e3f445e8 --- /dev/null +++ b/testsuite/sender-open-error.c @@ -0,0 +1,68 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +static int parent_fd = -1, failed_fd = -1; + +ssize_t readlinkat(int fd, const char *path, char *buffer, size_t length) +{ + ssize_t (*original)(int, const char *, char *, size_t) = dlsym(RTLD_NEXT, "readlinkat"); + ssize_t result = original(fd, path, buffer, length); + if (result < 0 && errno == EINVAL && getenv("RSYNC_TEST_OPEN_FILE") + && strcmp(path, getenv("RSYNC_TEST_OPEN_FILE")) == 0) + parent_fd = fd; + return result; +} + +static int open_file(const char *function, int fd, const char *path, int flags, mode_t mode) +{ + int (*original)(int, const char *, int, ...) = dlsym(RTLD_NEXT, function); + int error = getenv("RSYNC_TEST_OPEN_ERRNO") ? atoi(getenv("RSYNC_TEST_OPEN_ERRNO")) : 0; + if (error && fd == parent_fd && strcmp(path, getenv("RSYNC_TEST_OPEN_FILE")) == 0) { + int marker = open(getenv("RSYNC_TEST_OPEN_MARKER"), O_WRONLY | O_CREAT, 0600); + if (marker >= 0) + close(marker); + failed_fd = fd; + errno = error; + return -1; + } + return original(fd, path, flags, mode); +} + +int openat(int fd, const char *path, int flags, ...) +{ + va_list args; + mode_t mode = 0; + va_start(args, flags); + if ((flags & O_CREAT) || (flags & O_TMPFILE) == O_TMPFILE) + mode = va_arg(args, mode_t); + va_end(args); + return open_file("openat", fd, path, flags, mode); +} + +int openat64(int fd, const char *path, int flags, ...) +{ + va_list args; + mode_t mode = 0; + va_start(args, flags); + if ((flags & O_CREAT) || (flags & O_TMPFILE) == O_TMPFILE) + mode = va_arg(args, mode_t); + va_end(args); + return open_file("openat64", fd, path, flags, mode); +} + +int close(int fd) +{ + int (*original)(int) = dlsym(RTLD_NEXT, "close"); + int result = original(fd); + if (fd == failed_fd) { + failed_fd = -1; + errno = EBADF; + } + return result; +} diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 643aec3c6..49b1f0684 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -22,8 +22,10 @@ chroot-link-dest-inner-module chroot-receiver-write-inner-module chroot-special-inner-module copy-dest-symlink-readleak +copy-links-open-error # Linux interposition copy-xattrs-symlink-race daemon-auth-group +daemon-chroot-cwd # Linux interposition daemon-chroot-munge-default daemon-config-symlink daemon-module-chdir-symlink @@ -69,6 +71,7 @@ search-only-held-dirfd sender-remove-source-root-anchor simd-checksum source-change-size-continues +stdio-read-errors # Linux interposition symlink-dest-backupdir symlink-race-dest symlink-race-relative-dest diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 1311c90f5..e6f8cf576 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -13,8 +13,10 @@ append-abort-mid-write # the mid-write hook is an LD_PRELOAD, Linux-only backup-acl-xattr-cache backup-crossdev-copy chmod-temp-dir +copy-links-open-error # Linux interposition copy-xattrs-symlink-race daemon-auth-group +daemon-chroot-cwd # Linux interposition dir-sgid fake-super-acl-xattr hardlinks-emlink-recovery # LD_PRELOAD hook @@ -38,4 +40,5 @@ sender-remove-source-root-anchor simd-checksum source-change-size-continues sparse +stdio-read-errors # Linux interposition symlink-exclude-xattr diff --git a/testsuite/stdio-error.c b/testsuite/stdio-error.c new file mode 100644 index 000000000..3b0750e2e --- /dev/null +++ b/testsuite/stdio-error.c @@ -0,0 +1,126 @@ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include +#include +#include +#include +#include +#include +#include +#include + +struct reader { + int fd; + long left; + FILE *stream; + struct reader *next; +}; + +static struct reader *readers; +static int failed_fd = -1; + +static int close_fd(int fd) +{ + int (*original)(int) = dlsym(RTLD_NEXT, "close"); + return original(fd); +} + +int close(int fd) +{ + int result = close_fd(fd); + if (fd == failed_fd) { + failed_fd = -1; + errno = EBADF; + } + return result; +} + +static ssize_t read_cookie(void *cookie, char *buffer, size_t length) +{ + struct reader *reader = cookie; + const char *mode = getenv("RSYNC_TEST_READ_ERROR"); + ssize_t result; + if (reader->left == 0) { + int marker = open(getenv("RSYNC_TEST_READ_MARKER"), O_WRONLY | O_CREAT | O_APPEND, 0600); + if (marker >= 0) { + (void)write(marker, "x", 1); + close_fd(marker); + } + if (strcmp(mode, "eof") == 0) + return 0; + if (strcmp(mode, "eintr") == 0) { + reader->left = -1; + errno = EINTR; + } else + errno = EIO; + return -1; + } + if (reader->left > 0 && length > (size_t)reader->left) + length = reader->left; + result = read(reader->fd, buffer, length); + if (result > 0 && reader->left > 0) + reader->left -= result; + return result; +} + +static int close_cookie(void *cookie) +{ + struct reader *reader = cookie, **entry = &readers; + int result = close_fd(reader->fd); + while (*entry && *entry != reader) + entry = &(*entry)->next; + if (*entry) + *entry = reader->next; + free(reader); + return result; +} + +FILE *fdopen(int fd, const char *open_mode) +{ + FILE *(*original)(int, const char *) = dlsym(RTLD_NEXT, "fdopen"); + const char *path = getenv("RSYNC_TEST_READ_PATH"); + const char *error_mode = getenv("RSYNC_TEST_READ_ERROR"); + struct stat actual, target; + struct reader *reader; + FILE *stream; + cookie_io_functions_t functions = {.read = read_cookie, .close = close_cookie}; + if (!path || fstat(fd, &actual) < 0 || stat(path, &target) < 0 + || actual.st_dev != target.st_dev || actual.st_ino != target.st_ino) + return original(fd, open_mode); + if (strcmp(error_mode, "fdopen") == 0) { + int marker = open(getenv("RSYNC_TEST_READ_MARKER"), O_WRONLY | O_CREAT | O_APPEND, 0600); + if (marker >= 0) { + (void)write(marker, "x", 1); + close_fd(marker); + } + failed_fd = fd; + errno = EMFILE; + return NULL; + } + reader = calloc(1, sizeof *reader); + if (!reader) + return NULL; + reader->fd = fd; + reader->left = strtol(getenv("RSYNC_TEST_READ_AFTER"), NULL, 10); + stream = fopencookie(reader, open_mode, functions); + if (!stream) { + free(reader); + return NULL; + } + reader->stream = stream; + reader->next = readers; + readers = reader; + return stream; +} + +/* Keep the backing descriptor available to early-input's fstat(). */ +int fileno(FILE *stream) +{ + struct reader *reader; + int (*original)(FILE *) = dlsym(RTLD_NEXT, "fileno"); + for (reader = readers; reader; reader = reader->next) { + if (reader->stream == stream) + return reader->fd; + } + return original(stream); +} diff --git a/testsuite/stdio-read-errors_test.py b/testsuite/stdio-read-errors_test.py new file mode 100644 index 000000000..8b10993a0 --- /dev/null +++ b/testsuite/stdio-read-errors_test.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +import errno +import os +import platform +import shlex +import signal +import subprocess +import sys + +from rsyncfns import FROMDIR, SCRATCHDIR, SRCDIR, rsync_argv, start_test_daemon, test_fail, test_skipped + + +def test(): + if platform.system() != 'Linux' or os.environ.get('ASAN_OPTIONS'): + test_skipped('stdio fault injection needs Linux without ASan') + library = SCRATCHDIR / 'stdio-error.so' + fixture = SRCDIR / 'testsuite/stdio-error.c' + build = subprocess.run(['cc', '-shared', '-fPIC', '-o', str(library), str(fixture), '-ldl'], capture_output=True, text=True) + if build.returncode: + test_fail(f'cannot build stdio hook: {build.stderr}') + FROMDIR.mkdir() + (FROMDIR / 'keep').write_text('keep\n') + (FROMDIR / 'drop').write_text('drop\n') + config = SCRATCHDIR / 'rsyncd.conf' + config_text = f'use chroot = no\n[files]\npath = {FROMDIR}\n' + config.write_text(config_text) + url = start_test_daemon(config, 12918) + marker = SCRATCHDIR / 'read-error' + + def invoke(name, arguments, path, after, mode='eio', success=False): + marker.unlink(missing_ok=True) + env = {**os.environ, 'LD_PRELOAD': str(library), 'RSYNC_TEST_READ_PATH': str(path), + 'RSYNC_TEST_READ_AFTER': str(after), 'RSYNC_TEST_READ_ERROR': mode, + 'RSYNC_TEST_READ_MARKER': str(marker)} + with subprocess.Popen(rsync_argv(*arguments), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, start_new_session=True) as process: + try: + output, _ = process.communicate(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + output, _ = process.communicate(timeout=5) + test_fail(f'{name}: stalled after a read error: {output}') + if after >= 0 and not marker.exists(): + test_fail(f'{name}: read hook did not fire: {output}') + if process.returncode < 0: + test_fail(f'{name}: terminated by signal {-process.returncode}: {output}') + if (process.returncode == 0) != success: + test_fail(f'{name}: unexpected exit {process.returncode}: {output}') + return output + + invoke('config control', [url], config, -1, success=True) + for after in (0, len('use chroot = no\n'), config_text.index('[files]') + 3, len(config_text) - 3, len(config_text)): + invoke('config error', [url], config, after) + invoke('config fdopen error', [url], config, 0, mode='fdopen') + output = invoke('interrupted config', [url], config, config_text.index('[files]') + 3, mode='eintr', success=True) + if 'files' not in output: + test_fail('interrupted config lost the module name') + config.write_text('') + invoke('empty config', [url], config, -1, success=True) + config.write_text('# comment without a newline') + invoke('config comment', [url], config, -1, success=True) + config.write_text(config_text.rstrip('\n')) + invoke('config without newline', [url], config, -1, success=True) + included = SCRATCHDIR / 'included.conf' + included.write_text(f'[included]\npath = {FROMDIR}\n') + config.write_text(f'use chroot = no\n&include {included}\n' + config_text) + invoke('included config control', [url], included, -1, success=True) + invoke('included config error', [url], included, 3) + + motd = SCRATCHDIR / 'motd' + motd.write_text('message of the day\n') + config.write_text(f'motd file = {motd}\n' + config_text) + invoke('motd control', [url], motd, -1, success=True) + output = invoke('interrupted motd', [url], motd, 4, mode='eintr', success=True) + if motd.read_text() not in output: + test_fail('interrupted MOTD lost data') + invoke('motd error', [url], motd, 4) + invoke('motd fdopen error', [url], motd, 0, mode='fdopen') + motd.write_text('message of the day\n' * 1024) + output = invoke('interrupted long motd', [url], motd, 4099, mode='eintr', success=True) + if motd.read_text() not in output: + test_fail('interrupted MOTD lost data across buffers') + + early = SCRATCHDIR / 'early-input' + early.write_bytes(bytes(range(256))) + captured = SCRATCHDIR / 'early-received' + hook = shlex.join([sys.executable, '-S', '-c', + f'import sys; from pathlib import Path; Path({str(captured)!r}).write_bytes(sys.stdin.buffer.read())']) + config.write_text(config_text + f'early exec = {hook}\n') + arguments = [f'--early-input={early}', f'{url}files/'] + invoke('early-input control', arguments, early, -1, success=True) + invoke('interrupted early-input', arguments, early, 4, mode='eintr', success=True) + if captured.read_bytes() != early.read_bytes(): + test_fail('interrupted early-input lost or duplicated data') + invoke('early-input error', arguments, early, 4) + output = invoke('early-input fdopen error', arguments, early, 0, mode='fdopen') + if os.strerror(errno.EMFILE) not in output: + test_fail(f'early-input fdopen error was overwritten: {output}') + invoke('early-input short file', arguments, early, 4, mode='eof') + early.write_text('') + invoke('empty early-input', arguments, early, -1, success=True) + + rules = SCRATCHDIR / 'rules' + for ending in ('\n', '\r\n'): + rules.write_bytes(f'- drop{ending}'.encode()) + destination = SCRATCHDIR / f'destination-{len(ending)}' + arguments = ['-r', f'--exclude-from={rules}', f'{FROMDIR}/', f'{destination}/'] + invoke('filter control', arguments, rules, -1, success=True) + if not (destination / 'keep').exists() or (destination / 'drop').exists(): + test_fail('filter control copied the wrong files') + invoke('filter error', arguments, rules, len('- drop') + (ending == '\r\n')) + output = invoke('filter fdopen error', arguments, rules, 0, mode='fdopen') + if os.strerror(errno.EMFILE) not in output: + test_fail(f'filter fdopen error was overwritten: {output}') + invoke('interrupted filter', arguments, rules, 3, mode='eintr', success=True) + if (destination / 'drop').exists(): + test_fail('interrupted filter lost a rule') + + protected = SCRATCHDIR / 'protected-destination' + protected.mkdir() + (protected / 'protected').write_text('retained\n') + rules.write_text('- protected\n') + arguments = ['-r', '--delete', f'--exclude-from={rules}', f'{FROMDIR}/', f'{protected}/'] + invoke('filter deletion guard', arguments, rules, 3) + if (protected / 'protected').read_text() != 'retained\n': + test_fail('filter read error allowed deletion') + + nested = SCRATCHDIR / 'private-merge-name' + nested.write_text('- drop\n') + rules.write_text(f'. {nested}\n') + arguments = ['-r', '-f', f'. {rules}', f'{FROMDIR}/', f'{protected}/'] + output = invoke('nested filter error', arguments, nested, 3) + if nested.name in output: + test_fail('nested filter error exposed a filename from file contents') + + +if __name__ == '__main__': + test()