From d21fa49a6704324232902085c7fe9383bc432013 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 11 Nov 2026 22:21:56 +1100 Subject: [PATCH 1/6] io: handle stream read errors --- clientserver.c | 45 ++++++++- exclude.c | 38 ++++++-- params.c | 59 ++++++++---- testsuite/stdio-error.c | 126 +++++++++++++++++++++++++ testsuite/stdio-read-errors_test.py | 139 ++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 30 deletions(-) create mode 100644 testsuite/stdio-error.c create mode 100644 testsuite/stdio-read-errors_test.py diff --git a/clientserver.c b/clientserver.c index 586cc05bf..c547b65f3 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; 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..7849079bb 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,14 @@ 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; rprintf(FLOG, "%s Unexpected EOF in the config file: %s\n", func, bufr ); return( False ); } /* Section */ @@ -326,11 +338,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 +373,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 +391,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 +402,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 +410,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 +602,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 +637,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/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() From c79c61cfa2aa091a24ffb7177823760c91a0938f Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 11 Nov 2026 22:21:57 +1100 Subject: [PATCH 2/6] params: terminate truncated section names --- params.c | 1 + testsuite/daemon-config-truncation_test.py | 40 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 testsuite/daemon-config-truncation_test.py diff --git a/params.c b/params.c index 7849079bb..20d56d2bc 100644 --- a/params.c +++ b/params.c @@ -271,6 +271,7 @@ static BOOL Section( FILE *InFile, BOOL (*sfunc)(char *) ) /* 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 */ 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}') From 2b0ae5586e8966268af09a6d103f820e2acbfada Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 11 Nov 2026 22:21:57 +1100 Subject: [PATCH 3/6] sender: preserve file-open errors --- sender.c | 10 ++-- testsuite/copy-links-open-error_test.py | 48 +++++++++++++++++ testsuite/sender-open-error.c | 68 +++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 testsuite/copy-links-open-error_test.py create mode 100644 testsuite/sender-open-error.c 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/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/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; +} From aa12fc9265508b1ab40022fd2d574e4ffdb8fe87 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 11 Nov 2026 22:21:57 +1100 Subject: [PATCH 4/6] daemon: reset cwd after chroot --- NEWS.md | 9 +++++ clientserver.c | 2 + testsuite/chroot-cwd.c | 40 ++++++++++++++++++++ testsuite/daemon-chroot-cwd_test.py | 58 +++++++++++++++++++++++++++++ testsuite/skiplist/cygwin.txt | 3 ++ testsuite/skiplist/macos.txt | 3 ++ 6 files changed, 115 insertions(+) create mode 100644 testsuite/chroot-cwd.c create mode 100644 testsuite/daemon-chroot-cwd_test.py 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 c547b65f3..eafd231fd 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1093,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/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/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/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 From e6130287eb2a064a0bb3b65c648ff645b3eb3fc5 Mon Sep 17 00:00:00 2001 From: seks99x Date: Wed, 11 Nov 2026 19:28:07 +0300 Subject: [PATCH 5/6] Fix C-locale UTF-8 splitting and out-of-range codepoints in log.c When running in the 7-bit C/POSIX locale (MB_CUR_MAX == 1, such as in minimal containers without locale data installed) or without mbrtowc(), mbrtowc() rejects all bytes >= 0x80. The single-byte fallback in filtered_char_len() then passed leading bytes >= 0xa0 through raw while escaping continuation bytes in 0x80..0x9f as C1 controls, splitting valid UTF-8 characters like U+00DF (0xc3 0x9f) into a raw 0xc3 byte followed by an octal escape for 0x9f. Add a built-in UTF-8 decoder fallback (utf8_char_len) when MB_CUR_MAX == 1 or mbrtowc() is unavailable so valid UTF-8 sequences are preserved intact and UTF-8 C1 controls (U+0080..U+009F) are still escaped, while invalid, incomplete, overlong, surrogate, or > U+10FFFF sequences fall back to per-byte filtering. Also reject surrogates and codepoints > U+10FFFF from mbrtowc() so 4-byte sequences above U+10FFFF cannot smuggle raw 0x80..0x9f continuation bytes under permissive libc implementations. --- log.c | 58 ++++++++++++++++++++++--- testsuite/output-control-chars_test.py | 59 ++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/log.c b/log.c index bf30df98d..5ba218607 100644 --- a/log.c +++ b/log.c @@ -246,6 +246,41 @@ void logfile_reopen(void) } } +/* Decode one UTF-8 character when running in the 7-bit C/POSIX locale + * (MB_CUR_MAX == 1) where mbrtowc() rejects all bytes >= 0x80. */ +static size_t utf8_char_len(const char *buf, size_t len, int *is_control) +{ + const uchar *s = (const uchar *)buf; + uint32 cp; + size_t need; + + if (len < 2 || s[0] < 0xc2 || s[0] > 0xf4) + return 0; + if (s[0] < 0xe0) { + need = 2; + cp = s[0] & 0x1f; + } else if (s[0] < 0xf0) { + need = 3; + cp = s[0] & 0x0f; + } else { + need = 4; + cp = s[0] & 0x07; + } + if (len < need) + return 0; + for (size_t i = 1; i < need; i++) { + if ((s[i] & 0xc0) != 0x80) + return 0; + cp = (cp << 6) | (s[i] & 0x3f); + } + if ((need == 3 && cp < 0x800) || (need == 3 && cp >= 0xd800 && cp <= 0xdfff) + || (need == 4 && (cp < 0x10000 || cp > 0x10ffff))) + return 0; + + *is_control = (cp >= 0x7f && cp <= 0x9f); + return need; +} + /* Decode one character using the active locale. Invalid or incomplete input * returns 0 so its bytes are filtered individually. */ static size_t locale_char_len(const char *buf, size_t len, filter_mbstate *state, @@ -253,10 +288,24 @@ static size_t locale_char_len(const char *buf, size_t len, filter_mbstate *state { #if defined HAVE_WCHAR_H && defined HAVE_MBRTOWC wchar_t wc; - size_t char_len = mbrtowc(&wc, buf, len, state); + size_t char_len; + + if (MB_CUR_MAX == 1) { +#if defined HAVE_SETLOCALE && defined LC_CTYPE + const char *ctype = setlocale(LC_CTYPE, NULL); + if (!ctype || strcmp(ctype, "C") == 0 || strcmp(ctype, "POSIX") == 0) + return utf8_char_len(buf, len, is_control); +#else + return utf8_char_len(buf, len, is_control); +#endif + } - if (char_len == (size_t)-1 || char_len == (size_t)-2) { + char_len = mbrtowc(&wc, buf, len, state); + if (char_len == (size_t)-1 || char_len == (size_t)-2 + || (wc >= 0xd800 && wc <= 0xdfff) || wc > 0x10ffff) { memset(state, 0, sizeof *state); + if (MB_CUR_MAX == 1) + return utf8_char_len(buf, len, is_control); return 0; } if (char_len == 0) @@ -265,11 +314,8 @@ static size_t locale_char_len(const char *buf, size_t len, filter_mbstate *state && (wc < L' ' || (wc >= 0x7f && wc <= 0x9f)); return char_len; #else - (void)buf; - (void)len; (void)state; - (void)is_control; - return 0; + return utf8_char_len(buf, len, is_control); #endif } diff --git a/testsuite/output-control-chars_test.py b/testsuite/output-control-chars_test.py index 6b5595ecd..a3d69f43a 100644 --- a/testsuite/output-control-chars_test.py +++ b/testsuite/output-control-chars_test.py @@ -69,6 +69,13 @@ def check_locale_case(label, locale_names, name, expected, rejected): 'raw_csi': b'raw_\x9b_name', 'utf8_csi': b'utf8_\xc2\x9b_name', 'valid_utf8': b'valid_\xd8\x9b_name', + 'sharp_s': b'sharp_\xc3\x9f_name', + 'valid_4byte': b'four_\xf0\x9f\x92\xa9_name', + 'inc_prefix': b'inc_\xe2\x82', + 'bad_cont': b'badcont_\xe2\x28\xa1_name', + 'overlong': b'overlong_\xc0\xaf_name', + 'surrogate': b'surrogate_\xed\xa0\x80_name', + 'above_max': b'abovemax_\xf4\x90\x80\x80_name', 'leading_cr': b'\rleading_cr_name', 'delete': b'delete_\x7f_name', } @@ -104,6 +111,22 @@ def check_locale_case(label, locale_names, name, expected, rejected): if (utf8_locale is not None and 'valid_utf8' in created and names['valid_utf8'] not in output): test_fail("valid UTF-8 containing a C1-range continuation byte was changed") +if (utf8_locale is not None and 'sharp_s' in created + and names['sharp_s'] not in output): + test_fail("valid UTF-8 sharp s (0xc3 0x9f) was changed under UTF-8 locale") +if (utf8_locale is not None and 'valid_4byte' in created + and names['valid_4byte'] not in output): + test_fail("valid 4-byte UTF-8 (f0 9f 92 a9) was changed under UTF-8 locale") +if 'inc_prefix' in created and b'inc_\xe2\\#202\n' not in output: + test_fail(f"incomplete 3-byte prefix E2 82 did not escape 0x82: {output!r}") +if 'bad_cont' in created and names['bad_cont'] not in output: + test_fail(f"invalid continuation E2 28 A1 was not handled properly: {output!r}") +if 'overlong' in created and names['overlong'] not in output: + test_fail(f"overlong C0 AF was not handled properly: {output!r}") +if 'surrogate' in created and b'surrogate_\xed\xa0\\#200_name' not in output: + test_fail(f"surrogate ED A0 80 did not escape trailing 0x80: {output!r}") +if 'above_max' in created and b'abovemax_\xf4\\#220\\#200\\#200_name' not in output: + test_fail(f"above-U+10FFFF F4 90 80 80 did not escape C1 bytes: {output!r}") if 'leading_cr' in created and names['leading_cr'] in output: test_fail("leading carriage return reached terminal output") if 'leading_cr' in created and b'\\#015leading_cr_name' not in output: @@ -113,6 +136,42 @@ def check_locale_case(label, locale_names, name, expected, rejected): if 'delete' in created and b'delete_\\#177_name' not in output: test_fail("DEL was not escaped") +# Verify C/POSIX locale (or minimal container without locale database): +dst_c = base / 'dst-c' +dst_c.mkdir() +logfile_c = base / 'rsync-c.log' +env_c = os.environ.copy() +env_c['LC_ALL'] = 'C' +proc_c = subprocess.run( + rsync_argv('-av', '--8-bit-output', f'--log-file={logfile_c}', + '--log-file-format=%f', str(src) + '/', str(dst_c) + '/'), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env_c) +if proc_c.returncode != 0: + test_fail(f"rsync failed under C locale: {proc_c.stderr!r}") + +c_output = proc_c.stdout + proc_c.stderr + logfile_c.read_bytes() +if 'sharp_s' in created and names['sharp_s'] not in c_output: + test_fail(f"C locale mangled valid UTF-8 sharp s: {c_output!r}") +if 'valid_utf8' in created and names['valid_utf8'] not in c_output: + test_fail(f"C locale mangled valid_utf8: {c_output!r}") +if 'valid_4byte' in created and names['valid_4byte'] not in c_output: + test_fail(f"C locale mangled valid 4-byte UTF-8: {c_output!r}") +if 'utf8_csi' in created: + if b'\xc2\x9b' in c_output: + test_fail("C locale let UTF-8-encoded CSI reach output") + if b'\\#302\\#233' not in c_output: + test_fail("C locale did not escape UTF-8-encoded CSI") +if 'inc_prefix' in created and b'inc_\xe2\\#202\n' not in c_output: + test_fail(f"C locale did not escape 0x82 in incomplete prefix E2 82: {c_output!r}") +if 'bad_cont' in created and names['bad_cont'] not in c_output: + test_fail(f"C locale mishandled E2 28 A1: {c_output!r}") +if 'overlong' in created and names['overlong'] not in c_output: + test_fail(f"C locale mishandled overlong C0 AF: {c_output!r}") +if 'surrogate' in created and b'surrogate_\xed\xa0\\#200_name' not in c_output: + test_fail(f"C locale did not escape trailing 0x80 in surrogate ED A0 80: {c_output!r}") +if 'above_max' in created and b'abovemax_\xf4\\#220\\#200\\#200_name' not in c_output: + test_fail(f"C locale did not escape C1 bytes in F4 90 80 80: {c_output!r}") + locale_cases = 0 locale_cases += check_locale_case( 'iso-8859-1', ('en_US.ISO-8859-1', 'no_NO.ISO-8859-1'), From 62b7e3f34710a558430f0c7706911083c9130372 Mon Sep 17 00:00:00 2001 From: seks99x Date: Wed, 11 Nov 2026 20:14:24 +0300 Subject: [PATCH 6/6] Guard mbrtowc() Unicode bounds check with __STDC_ISO_10646__ in log.c On platforms that do not define __STDC_ISO_10646__ (such as NetBSD and Solaris), wchar_t in non-UTF-8 locales like ja_JP.eucJP stores packed codeset bits that exceed 0x10ffff rather than UCS-4 codepoints. Guard the surrogate and > U+10FFFF check on mbrtowc() output with __STDC_ISO_10646__ so valid EUC-JP multibyte characters are not rejected on BSD and Solaris. --- log.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/log.c b/log.c index 5ba218607..16649b58d 100644 --- a/log.c +++ b/log.c @@ -302,7 +302,10 @@ static size_t locale_char_len(const char *buf, size_t len, filter_mbstate *state char_len = mbrtowc(&wc, buf, len, state); if (char_len == (size_t)-1 || char_len == (size_t)-2 - || (wc >= 0xd800 && wc <= 0xdfff) || wc > 0x10ffff) { +#ifdef __STDC_ISO_10646__ + || (wc >= 0xd800 && wc <= 0xdfff) || wc > 0x10ffff +#endif + ) { memset(state, 0, sizeof *state); if (MB_CUR_MAX == 1) return utf8_char_len(buf, len, is_control);