From 41bbd895668767e155b2eb6da64f37afa9cb8724 Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Wed, 4 Nov 2026 06:52:39 +1000 Subject: [PATCH] Prepare rsync 3.5.2 (#1141) * NEWS: prepare rsync 3.5.2 * packaging: simplify the release workflow * website: prepare rsync 3.5.2 * release: document CVE-2026-119741 * release: finalise rsync 3.5.2 --- NEWS.md | 43 ++++- SECURITY.md | 7 +- packaging/lsb/rsync.spec | 6 +- packaging/pkglib.py | 2 +- packaging/release.py | 334 +++++++++++++++++++++++++++++---------- packaging/year-tweak | 5 + rsync-web/download.html | 18 ++- rsync-web/index.html | 24 ++- rsync-web/security.html | 17 +- version.h | 4 +- 10 files changed, 352 insertions(+), 108 deletions(-) diff --git a/NEWS.md b/NEWS.md index c77588c7..45f9e1b8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,8 +1,28 @@ -# NEWS for UNRELEASED +# NEWS for rsync 3.5.2 (4 Nov 2026) ## Changes in this version: ### BUG FIXES: +- Fixed interrupted `--append` and `--inplace` transfers writing the final + 256 KiB buffer twice. An interrupted destination now remains a clean prefix + which the next transfer can resume or repair. +- Fixed uploads through `rrsync` when the destination file already exists. + Existing files are now reached beneath a pinned parent directory instead of + being passed as an unwritable `/proc/self/fd/N` destination. +- Fixed poor matching performance on large sparse and repeated-block files + when duplicate checksum chains exhausted the search limit. Valid basis + blocks are no longer hidden behind duplicate entries. +- Bounded strong-checksum work when a malicious receiver supplies weak + checksums that collide with consecutive rolling windows + (CVE-2026-119741). +- Prevented the `--inplace` matcher from advancing its aligned basis index + beyond the available checksum entries. This avoids signed overflow on large + source files when the peer supplies a small block count. +- Recovered from failed hard-link creation without aborting or hanging the + generator. Remaining members fall back to a fresh hard-link group when the + original destination inode cannot accept another link. +- Prevented an out-of-bounds write while invalidating a sender checksum after + a file read error when the negotiated checksum length is zero. - Fixed a daemon receiver stack overflow when queued deletion output reached `--max-alloc`. Quiet daemon transfers no longer queue deletion messages unless the client requested them. @@ -10,6 +30,21 @@ read each listed file from the module root instead. It could send, and with `--remove-source-files` delete, a same-named file the module's filter excludes. +- Fixed password files whose first line is empty. The parser no longer skips + the empty line and uses a later password. +- Escaped terminal control characters in filenames and diagnostics written to + standard output, standard error and log files while preserving printable + text in the active locale. +- Preserved Cygwin and MSYS2 UNC roots during ownership-checked path walks. +- Restored daemon startup from accepted named Unix sockets on standard input, + including filesystem and Linux abstract sockets used for socket activation. + Unnamed local socket pairs remain non-daemon channels. +- Fixed forward-DNS hostname entries in daemon `hosts allow` and `hosts deny` + lists for IPv6 peers. Address comparison is now binary and family-aware. +- Preserved Unix symlink permissions on systems which require `fchmodat()` to + change the mode of a symlink rather than its target. +- Restored builds on systems without `O_CLOEXEC` or `O_DIRECTORY`, including + Solaris 10. ### ENHANCEMENTS: - Added `--nice` to adjust CPU and I/O scheduling priorities for local and @@ -22,7 +57,10 @@ `113,295B/s`. File-list, created-directory and skipped-deletion counts also use thousands separators. - Added `--delay-symlinks` to defer creating new and changed symlinks until - other transferred files are in place. + other transferred files are in place. It implies `--no-inc-recursive` and + requires protocol 29 or newer with support on the receiving side. It cannot + be combined with `--remove-source-files`, `--hard-links`, `--compare-dest`, + `--copy-dest` or `--link-dest`. ------------------------------------------------------------------------------ @@ -5799,6 +5837,7 @@ to develop and test fixes. | RELEASE DATE | VER. | DATE OF COMMIT\* | PROTOCOL | |--------------|--------|------------------|-------------| +| 04 Nov 2026 | 3.5.2 | | 33 | | 21 Sep 2026 | 3.5.1 | 21 Sep 2026 | 33 | | 13 Aug 2026 | 3.5.0 | | 32 | | 08 Jun 2026 | 3.4.4 | | 32 | diff --git a/SECURITY.md b/SECURITY.md index b77e1473..fa5af4b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -88,9 +88,10 @@ characters in a (peer-controlled) filename written to the log file are escaped, so a name carrying C0/C1 terminal-escape bytes cannot inject sequences into an administrator's terminal when the log is viewed (CWE-117). The number of equal-weak-checksum blocks `hash_search()` examines per offset is also bounded -(issue #217), so a crafted or degenerate checksum set with a very long -equal-checksum chain cannot drive the sender's per-offset match-verify into a -quadratic walk and pin one connection's CPU. +(CVE-2026-70453, issue #217), and consecutive weak-checksum collisions cannot +force a full strong-checksum calculation at every byte offset +(CVE-2026-119741). These bounds prevent a crafted checksum set from pinning a +sender process in `hash_search()` Contributors adding code that consumes peer input should validate it at the point of receipt rather than relying on a downstream check. diff --git a/packaging/lsb/rsync.spec b/packaging/lsb/rsync.spec index 53427162..9a0ea369 100644 --- a/packaging/lsb/rsync.spec +++ b/packaging/lsb/rsync.spec @@ -1,6 +1,6 @@ Summary: A fast, versatile, remote (and local) file-copying tool Name: rsync -Version: 3.5.1 +Version: 3.5.2 %define fullversion %{version} Release: 1 %define srcdir src @@ -79,5 +79,5 @@ rm -rf $RPM_BUILD_ROOT %dir /etc/rsync-ssl/certs %changelog -* Mon Sep 21 2026 Rsync Project -Released 3.5.1. +* Wed Nov 04 2026 Rsync Project +Released 3.5.2. diff --git a/packaging/pkglib.py b/packaging/pkglib.py index 2ae564fa..f3d4e12b 100644 --- a/packaging/pkglib.py +++ b/packaging/pkglib.py @@ -211,7 +211,7 @@ def get_NEWS_version_info(skip_version=None): skip_version lets the caller exclude the version it is about to release. Its NEWS entry may already carry a release date -- dated by hand, or by an - earlier run of --step-3-tweak -- and would otherwise be reported as the + earlier run of --prepare-pr -- and would otherwise be reported as the PREVIOUS release, which is both wrong and fatal when it has no table row yet. """ rel_re = re.compile(r'^\| \S{2} \w{3} \d{4}\s+\|\s+(?P\d+\.\d+\.\d+)\s+\|\s+(?P\d{2} \w{3} \d{4})?\s+\|\s+(?P\d+)\s+\|') diff --git a/packaging/release.py b/packaging/release.py index 3ff90f35..186148c9 100755 --- a/packaging/release.py +++ b/packaging/release.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -# Step-based release script for rsync. Each step is a separate invocation -# selected by a --step-N-XX option, so the maintainer drives the release -# manually one piece at a time. +# Phase-based release script for rsync. The prepare-pr phase updates tracked +# release files for review. After that PR is merged, stage-release creates +# the signed tag and release artefacts without publishing them. The final +# publish-release phase updates the public mirrors after confirmation. # -# All persistent state and working files live in ../release/ (a sibling of -# the rsync git checkout): +# Persistent state and working files live in a release/ directory beside the +# primary rsync checkout. Worktrees share that directory. RSYNC_RELEASE_DIR +# can override it. # # ../release/rsync-ftp/ mirror of samba.org:/home/ftp/pub/rsync # ../release/rsync-html/ release-time snapshot of the html site @@ -16,29 +18,46 @@ # # Run "packaging/release.py --list" to see the step list. -import os, sys, re, argparse, glob, shutil, json, signal, subprocess +import os, sys, re, argparse, glob, shutil, json, signal, subprocess, filecmp from datetime import datetime sys.path = ['packaging'] + sys.path from pkglib import ( warn, die, cmd_run, cmd_chk, cmd_txt, cmd_txt_chk, cmd_pipe, - check_git_state, get_rsync_version, + get_rsync_version, get_NEWS_version_info, get_protocol_versions, ) # ---------- Paths ---------- -RELEASE_DIR = os.path.realpath('../release') +def default_release_dir(): + override = os.environ.get('RSYNC_RELEASE_DIR') + if override: + return os.path.realpath(override) + checkout = os.path.realpath('.') + project_dir = os.path.dirname(checkout) + if os.path.basename(project_dir) == 'worktrees': + project_dir = os.path.dirname(project_dir) + try: + proc = subprocess.run( + ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], + check=True, capture_output=True, encoding='utf-8') + common_dir = os.path.realpath(proc.stdout.strip()) + return os.path.realpath(os.path.join(os.path.dirname(common_dir), '..', 'release')) + except (OSError, subprocess.SubprocessError): + return os.path.join(project_dir, 'release') + + +RELEASE_DIR = default_release_dir() FTP_DIR = os.path.join(RELEASE_DIR, 'rsync-ftp') HTML_DIR = os.path.join(RELEASE_DIR, 'rsync-html') WORK_DIR = os.path.join(RELEASE_DIR, 'work') STATE_FILE = os.path.join(RELEASE_DIR, 'release-state.json') # The rsync-web/ subdirectory in the rsync source tree is the source-of-truth -# for the git-tracked html content. step-1-fetch snapshots it into HTML_DIR -# for the release flow, where it can be edited or augmented with server-side -# content before step-11-push-html sends it to samba.org. +# for the git-tracked html content. stage-release snapshots it into HTML_DIR +# for the release flow and augments it with filtered server-side content. HTML_SRC = os.path.realpath('rsync-web') FTP_REMOTE_PATH = '/home/ftp/pub/rsync' @@ -47,7 +66,7 @@ HTML_REMOTE_PATH = '/home/httpd/html/rsync' # Files that ./configure + make produce and that the release tarball / diff # need to bundle alongside the git-tracked source. Mirrors the GENFILES # definition in Makefile.in (with rrsync.1{,.html} since we always configure -# --with-rrsync in --step-4-build). +# --with-rrsync during the release build). GEN_FILES = [ 'configure.sh', 'aclocal.m4', @@ -58,23 +77,13 @@ GEN_FILES = [ 'rrsync.1', 'rrsync.1.html', ] -# ---------- Step registry ---------- +# ---------- Phase registry ---------- -STEPS = [ - ('step-1-fetch', 'mirror ../release/rsync-ftp from samba.org and snapshot ../release/rsync-html from rsync-web/'), - ('step-2-prepare', 'gather release info interactively and write release-state.json'), - ('step-3-tweak', 'update version.h, rsync.h, NEWS.md, and packaging/*.spec'), - ('step-4-build', 'run smart-make + make gen'), - ('step-5-commit', 'git commit -a (commit the prepared release changes)'), - ('step-6-tag', 'create the gpg-signed git tag'), - ('step-7-tarball', 'build the source tarball and diffs.gz against the previous release'), - ('step-8-update-ftp', 'refresh README/NEWS/INSTALL/html in the ftp dir, regen ChangeLog.gz, gpg-sign tarballs'), - ('step-9-toplinks', 'hard-link top-level release files (final releases only)'), - ('step-10-push-ftp', 'rsync ../release/rsync-ftp/ to samba.org'), - ('step-11-push-html', 'rsync ../release/rsync-html/ to samba.org (after any manual edits)'), - ('step-12-push-git', 'print the git push commands for you to run'), +PHASES = [ + ('prepare-pr', 'update tracked release files and run the release build'), + ('stage-release', 'fetch mirrors, verify the merged PR, tag, build and sign artefacts'), + ('publish-release', 'publish the FTP and website trees and print final release actions'), ] -STEP_FLAGS = [s[0] for s in STEPS] DASH_LINE = '=' * 74 @@ -82,7 +91,7 @@ DASH_LINE = '=' * 74 def load_state(): if not os.path.isfile(STATE_FILE): - die(f"{STATE_FILE} not found. Run --step-2-prepare first.") + die(f"{STATE_FILE} not found. Run --prepare-pr first.") with open(STATE_FILE, 'r', encoding='utf-8') as fh: return json.load(fh) @@ -119,6 +128,15 @@ def section(title): print(f"\n{DASH_LINE}\n== {title}\n{DASH_LINE}") +def verify_html_snapshot(): + tracked = cmd_txt_chk(['git', 'ls-files', '-z', '--', 'rsync-web']).out.split('\0') + for source in filter(None, tracked): + relative = os.path.relpath(os.path.realpath(source), HTML_SRC) + staged = os.path.join(HTML_DIR, relative) + if not os.path.isfile(staged) or not filecmp.cmp(source, staged, shallow=False): + die(f"Staged website file differs from reviewed source: {source}") + + def confirm(prompt, default_no=True): suffix = '[n] ' if default_no else '[y] ' ans = input(f"{prompt} {suffix}").strip().lower() @@ -139,9 +157,9 @@ def step_1_fetch(args): os.makedirs(FTP_DIR) # packaging/ftp.filt is the authoritative copy of the .filt filter file # that controls which subtrees rsync excludes from the FTP mirror. - # Seed FTP_DIR/.filt from it so the bundled version is what step-1's - # rsync uses here, and so step-10-push-ftp propagates it back to the - # server. --exclude=/.filt below stops the server's copy from + # Seed FTP_DIR/.filt from it so the bundled version is used for the pull + # and later propagated back to the server during publication. + # --exclude=/.filt below stops the server's copy from # overwriting our bundled one on the way down. filt = os.path.join(FTP_DIR, '.filt') bundled_filt = os.path.realpath('packaging/ftp.filt') @@ -151,23 +169,28 @@ def step_1_fetch(args): cmd_chk(['rsync', '-aivOHP', f'-f:_{filt}', '--exclude=/.filt', f'{host}:{FTP_REMOTE_PATH}/', f'{FTP_DIR}/']) - section(f"Snapshotting html dir from {HTML_SRC} into {HTML_DIR}") + section(f"Fetching server-only html content into {HTML_DIR}") if not os.path.isdir(HTML_SRC): die(f"{HTML_SRC} not found. This should be the in-tree rsync-web/ " f"subdirectory; something is wrong with your checkout.") + if os.path.isdir(HTML_DIR): + shutil.rmtree(HTML_DIR) os.makedirs(HTML_DIR, exist_ok=True) - cmd_chk(['rsync', '-aiv', f'{HTML_SRC}/', f'{HTML_DIR}/']) - # Then mirror non-git html content from the server, skipping files that - # the html git already provides (driven by the 'filt' file in HTML_DIR). - filt = os.path.join(HTML_DIR, 'filt') + # Mirror non-git content from the server first. The reviewed rsync-web + # source is overlaid afterwards and therefore remains authoritative. + filt = os.path.join(HTML_SRC, 'filt') if os.path.exists(filt): - tmp_filt = os.path.join(HTML_DIR, 'tmp-filt') + tmp_filt = os.path.join(WORK_DIR, 'html-pull.filt') cmd_chk(f"sed -n -e 's/[-P]/H/p' '{filt}' >'{tmp_filt}'") cmd_chk(['rsync', '-aivOHP', f'-f._{tmp_filt}', f'{host}:{HTML_REMOTE_PATH}/', f'{HTML_DIR}/']) os.unlink(tmp_filt) + section(f"Overlaying reviewed html from {HTML_SRC}") + cmd_chk(['rsync', '-aiv', f'{HTML_SRC}/', f'{HTML_DIR}/']) + verify_html_snapshot() + print(f"\nFetch complete. Local dirs are now in {RELEASE_DIR}.") @@ -177,22 +200,24 @@ def step_2_prepare(args): require_top_of_checkout() os.makedirs(RELEASE_DIR, exist_ok=True) - if not os.path.isdir(FTP_DIR): - die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.") - now = datetime.now().astimezone() cl_today = now.strftime('* %a %b %d %Y') year = now.strftime('%Y') ztoday = now.strftime('%d %b %Y') today = ztoday.lstrip('0') + web_today = f'{now.day} {now.strftime("%B %Y")}' tz_now = now.strftime('%z') tz_num = tz_now[0:1].replace('+', '') + str(float(tz_now[1:3]) + float(tz_now[3:]) / 60) curversion = get_rsync_version() + with open('NEWS.md', 'r', encoding='utf-8') as fh: + news_top = fh.readline() + m = re.match(r'^# NEWS for rsync (\d+\.\d+\.\d+)', news_top) + news_version = m[1] if m else None # Skip the version we are releasing: its NEWS entry may already be dated, # in which case it would otherwise be taken for the previous release. lastversion, last_protocol_version, pdate = get_NEWS_version_info( - skip_version=re.sub(r'(pre\d+|dev)$', '', curversion)) + skip_version=news_version or re.sub(r'(pre\d+|dev)$', '', curversion)) protocol_version, subprotocol_version = get_protocol_versions() # Default next version: bump preN, or move dev -> pre1. @@ -202,6 +227,8 @@ def step_2_prepare(args): version = re.sub(r'pre\d+', 'pre' + str(int(m[1]) + 1), version) else: version = version.replace('dev', 'pre1') + if version == curversion and news_version and news_version != curversion: + version = news_version print(f"\nCurrent version (version.h): {curversion}") print(f"Last released version (NEWS.md): {lastversion}") @@ -273,6 +300,7 @@ def step_2_prepare(args): 'srcdiffdir': srcdiffdir, 'lastsrcdir': lastsrcdir, 'today': today, + 'web_today': web_today, 'ztoday': ztoday, 'cl_today': cl_today, 'year': year, @@ -285,7 +313,7 @@ def step_2_prepare(args): for k in ('version', 'lastversion', 'release', 'srcdir', 'srcdiffdir', 'lastsrcdir', 'protocol_version', 'proto_changed', 'proto_change_date'): print(f" {k}: {state[k]}") - print(f"\nWrote {STATE_FILE}. Re-run --step-2-prepare to change anything.") + print(f"\nWrote {STATE_FILE}. Re-run --prepare-pr to change anything.") # ---------- Step 3: tweak version files ---------- @@ -317,6 +345,8 @@ def step_3_tweak(args): } tweak_files = ['version.h', 'rsync.h', 'NEWS.md'] + if not pre: + tweak_files += ['rsync-web/index.html', 'rsync-web/security.html'] tweak_files += glob.glob('packaging/*.spec') tweak_files += glob.glob('packaging/*/*.spec') @@ -344,7 +374,7 @@ def step_3_tweak(args): # release entry that was dated by hand (or by an earlier run of # this step) does not have to be reverted before releasing. x_re = re.compile( - r'^# NEWS for rsync %s \((?:UNRELEASED|\d+ \w{3} \d{4})\)\s+## Changes in this version:\n' % efv + r'^# NEWS for rsync %s \((?:UNRELEASED|\?\? \?\?\? \d{4}|\d+ \w{3} \d{4})\)\s+## Changes in this version:\n' % efv + r'(\n### PROTOCOL NUMBER:\s+- The protocol number was changed to \d+\.\n)?') rel_day = 'UNRELEASED' if pre else today repl = (f'# NEWS for rsync {finalversion} ({rel_day})\n\n' @@ -362,6 +392,15 @@ def step_3_tweak(args): + proto_change_date + m[4] + protocol_version + m[5]) txt = replace_or_die(x_re, repl, txt, f'Unable to find "| ?? ??? {year} | {finalversion} | ... |" line in {fn}') + elif fn in ('rsync-web/index.html', 'rsync-web/security.html'): + heading = (f'Rsync version {version} released' + if fn.endswith('index.html') else f'Security fix in {version}') + x_re = re.compile( + rf'(

{re.escape(heading)}

\s*' + r')(?:\?\? \?\?\? \d{4}|\d+ \w+ \d{4})()') + txt = replace_or_die( + x_re, rf'\g<1>{state["web_today"]}\2', txt, + f'Unable to find the {version} release date in {fn}') elif '.spec' in fn: for var, val in specvars.items(): x_re = re.compile(r'^%s .*' % re.escape(var), re.M) @@ -404,18 +443,83 @@ def step_4_build(args): cmd_chk(['make', 'gen']) -# ---------- Step 5: commit ---------- +# ---------- Step 5: verify merged release source ---------- -def step_5_commit(args): +def step_5_verify(args): require_top_of_checkout() state = load_state() version = state['version'] + finalversion = state['finalversion'] + lastversion = state['lastversion'] + pre = state['pre'] + master_branch = state['master_branch'] - section("git status") - cmd_run(['git', 'status']) - if not confirm("Commit all current changes with the release message?"): - die("Aborted.") - cmd_chk(['git', 'commit', '-a', '-m', f'Preparing for release of {version} [buildall]']) + branch = cmd_txt_chk(['git', 'branch', '--show-current']).out.strip() + if branch != master_branch: + die(f"Release staging requires branch {master_branch}, not {branch}.") + dirty = cmd_txt_chk(['git', 'status', '--porcelain=v1']).out + if dirty: + die("Release staging requires a clean checkout:\n" + dirty) + cmd_chk(['git', 'fetch', 'origin', master_branch]) + head = cmd_txt_chk(['git', 'rev-parse', 'HEAD']).out.strip() + upstream = cmd_txt_chk(['git', 'rev-parse', f'origin/{master_branch}']).out.strip() + if head != upstream: + die(f"Release commit {head} does not match origin/{master_branch} at {upstream}.") + + if get_rsync_version() != version: + die(f"version.h reports {get_rsync_version()}, expected {version}.") + if not pre: + release_day = datetime.now().astimezone().strftime('%d %b %Y').lstrip('0') + if state['today'] != release_day: + die(f"Release metadata is dated {state['today']}, not {release_day}. " + "Prepare a corrected release PR before staging.") + + expected_news = (f'# NEWS for rsync {finalversion} (UNRELEASED)' + if pre else f'# NEWS for rsync {finalversion} ({state["today"]})') + checks = { + 'NEWS.md': [expected_news], + 'packaging/lsb/rsync.spec': [f'Version: {finalversion}', f'Released {version}.'], + } + if not pre: + checks['rsync-web/index.html'] = [ + f'Rsync version {version} released', + f'{state["web_today"]}', + f'rsync-{version}.tar.gz', + f'rsync-{lastversion}-{version}.diffs.gz', + ] + checks['rsync-web/security.html'] = [ + f'Security fix in {version}', + f'{state["web_today"]}', + 'CVE-2026-119741', + ] + + for fn, required in checks.items(): + with open(fn, 'r', encoding='utf-8') as fh: + txt = fh.read() + for value in required: + if value not in txt: + die(f'{fn} does not contain the expected release text: {value}') + + section(f"Release source verified for {version}") + + +def release_preflight(): + missing = [command for command in ('fakeroot', 'git', 'gpg', 'gzip', 'rsync', 'sed', 'tar') + if not shutil.which(command)] + if missing: + die("Missing release commands: " + ', '.join(missing)) + section("Checking release markdown conversion") + cmd_chk(['./md-convert', '--test', 'NEWS.md']) + + +def verify_artefact_inputs(): + state = load_state() + required = list(GEN_FILES) + required.append(os.path.join( + FTP_DIR, state['lastsrcdir'], f'rsync-{state["lastversion"]}.tar.gz')) + missing = [fn for fn in required if not os.path.isfile(fn)] + if missing: + die("Missing release inputs:\n " + '\n '.join(missing)) # ---------- Step 6: tag ---------- @@ -426,11 +530,22 @@ def step_6_tag(args): version = state['version'] v_ver = 'v' + version + head = cmd_txt_chk(['git', 'rev-parse', 'HEAD']).out.strip() out = cmd_txt_chk(['git', 'tag', '-l', v_ver]).out if out.strip(): - if not confirm(f"Tag {v_ver} already exists. Delete and recreate?"): - die("Aborted.") - cmd_chk(['git', 'tag', '-d', v_ver]) + target = cmd_txt_chk(['git', 'rev-list', '-n', '1', v_ver]).out.strip() + if target != head: + die(f"Tag {v_ver} already exists at {target}, not release commit {head}.") + section(f"Reusing signed tag {v_ver}") + cmd_chk(['git', 'tag', '-v', v_ver]) + return + + remote = cmd_txt(['git', 'ls-remote', '--exit-code', '--tags', 'origin', + f'refs/tags/{v_ver}'], capture='combined') + if remote.rc == 0: + die(f"Tag {v_ver} already exists on origin. Refusing to recreate it.") + if remote.rc != 2: + die(f"Unable to check whether {v_ver} exists on origin:\n{remote.out}") # Prime the gpg agent so the actual tag signing won't prompt. section("Priming gpg agent") @@ -509,7 +624,7 @@ def step_7_tarball(args): cmd_chk(['fakeroot', 'tar', '-C', WORK_DIR, '-czf', srctar_file, '--exclude=.github', rsync_ver]) - # Leave staging in place; --step-8-update-ftp does its own thing. + # Leave staging in place for the FTP refresh. print(f"\nCreated:\n {srctar_file}\n {diff_file}") @@ -545,7 +660,7 @@ def step_8_update_ftp(args): for fn in (srctar_file, diff_file): if not os.path.isfile(fn): - die(f"Missing file to sign: {fn}. Did --step-7-tarball run successfully?") + die(f"Missing file to sign: {fn}. The artefact build did not complete.") asc_fn = fn + '.asc' if os.path.lexists(asc_fn): os.unlink(asc_fn) @@ -601,7 +716,7 @@ def step_9_toplinks(args): def step_10_push_ftp(args): host = require_samba_host() if not os.path.isdir(FTP_DIR): - die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.") + die(f"{FTP_DIR} does not exist. Run --stage-release first.") section(f"rsync ftp dir to {host}") rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del', f'-f._{os.path.join(FTP_DIR, ".filt")}', @@ -613,7 +728,7 @@ def step_10_push_ftp(args): def step_11_push_html(args): host = require_samba_host() if not os.path.isdir(HTML_DIR): - die(f"{HTML_DIR} does not exist. Run --step-1-fetch first.") + die(f"{HTML_DIR} does not exist. Run --stage-release first.") section(f"rsync html dir to {host}") filt = os.path.join(HTML_DIR, 'filt') rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del', @@ -621,24 +736,21 @@ def step_11_push_html(args): f'{HTML_DIR}/', f'{host}:{HTML_REMOTE_PATH}/']) -# ---------- Step 12: print push-git instructions ---------- +# ---------- Step 12: print final release instructions ---------- -def step_12_push_git(args): +def step_12_finish(args): state = load_state() version = state['version'] - master_branch = state['master_branch'] v_ver = 'v' + version print(f"""\ {DASH_LINE} -Run these from the rsync-git checkout (this script does not push for you): +The release PR has already updated master. Push the signed tag from this checkout: - git push origin {master_branch} git push origin {v_ver} If you have a 'samba' remote configured (git.samba.org:/data/git/rsync.git): - git push samba {master_branch} git push samba {v_ver} Then upload the tarball + .asc to the GitHub release for {v_ver}, @@ -657,21 +769,69 @@ def rsync_with_confirm(rsync_args): cmd_run(['rsync', *rsync_args]) -# ---------- dispatch ---------- +# ---------- phases ---------- -STEP_FUNCS = { - 'step-1-fetch': step_1_fetch, - 'step-2-prepare': step_2_prepare, - 'step-3-tweak': step_3_tweak, - 'step-4-build': step_4_build, - 'step-5-commit': step_5_commit, - 'step-6-tag': step_6_tag, - 'step-7-tarball': step_7_tarball, - 'step-8-update-ftp': step_8_update_ftp, - 'step-9-toplinks': step_9_toplinks, - 'step-10-push-ftp': step_10_push_ftp, - 'step-11-push-html': step_11_push_html, - 'step-12-push-git': step_12_push_git, +def phase_prepare_pr(args): + step_2_prepare(args) + step_3_tweak(args) + step_4_build(args) + section("Release PR source prepared") + print("Review the rsync-web release announcement in this PR before merging it.") + + +def phase_stage_release(args): + step_4_build(args) + step_5_verify(args) + release_preflight() + step_1_fetch(args) + verify_artefact_inputs() + step_6_tag(args) + step_7_tarball(args) + step_8_update_ftp(args) + step_9_toplinks(args) + state = load_state() + state['staged_commit'] = cmd_txt_chk(['git', 'rev-parse', 'HEAD']).out.strip() + save_state(state) + + +def phase_publish_release(args): + state = load_state() + dirty = cmd_txt_chk(['git', 'status', '--porcelain=v1']).out + if dirty: + die("Release publication requires a clean checkout:\n" + dirty) + head = cmd_txt_chk(['git', 'rev-parse', 'HEAD']).out.strip() + if state.get('staged_commit') != head: + die("The current commit was not staged by --stage-release.") + + version = state['version'] + lastversion = state['lastversion'] + tag = 'v' + version + target = cmd_txt_chk(['git', 'rev-list', '-n', '1', tag]).out.strip() + if target != head: + die(f"Tag {tag} does not point to staged commit {head}.") + cmd_chk(['git', 'tag', '-v', tag]) + + artefacts = [ + os.path.join(FTP_DIR, state['srcdir'], f'rsync-{version}.tar.gz'), + os.path.join(FTP_DIR, state['srcdiffdir'], + f'rsync-{lastversion}-{version}.diffs.gz'), + ] + for fn in artefacts: + if not os.path.isfile(fn) or not os.path.isfile(fn + '.asc'): + die(f"Missing staged release artefact or signature: {fn}") + cmd_chk(['gpg', '--verify', fn + '.asc', fn]) + + verify_html_snapshot() + + step_10_push_ftp(args) + step_11_push_html(args) + step_12_finish(args) + + +PHASE_FUNCS = { + 'prepare-pr': phase_prepare_pr, + 'stage-release': phase_stage_release, + 'publish-release': phase_publish_release, } @@ -681,31 +841,31 @@ def signal_handler(sig, frame): def main(): parser = argparse.ArgumentParser( - description="Step-based release script for rsync.", + description="Phase-based release script for rsync.", formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="Run --list to see the steps. Each invocation runs exactly one --step-* option.") + epilog="Run --list to see the release phases. Each invocation runs one phase.") parser.add_argument('--branch', '-b', dest='master_branch', default='master', help="The branch to release (default: master).") parser.add_argument('--list', action='store_true', - help="List all release steps and exit.") + help="List all release phases and exit.") grp = parser.add_mutually_exclusive_group() - for flag, descr in STEPS: - grp.add_argument('--' + flag, dest='step', action='store_const', + for flag, descr in PHASES: + grp.add_argument('--' + flag, dest='phase', action='store_const', const=flag, help=descr) args = parser.parse_args() if args.list: - print("Release steps:") - for flag, descr in STEPS: + print("Release phases:") + for flag, descr in PHASES: print(f" --{flag:18s} {descr}") return - if not args.step: - parser.error("pick one --step-N-XX option (or --list to see them).") + if not args.phase: + parser.error("pick one release phase (or --list to see them).") signal.signal(signal.SIGINT, signal_handler) os.environ['LESS'] = 'mqeiXR' - STEP_FUNCS[args.step](args) + PHASE_FUNCS[args.phase](args) if __name__ == '__main__': diff --git a/packaging/year-tweak b/packaging/year-tweak index 8a7fb98e..0a22b8f0 100755 --- a/packaging/year-tweak +++ b/packaging/year-tweak @@ -9,6 +9,7 @@ from datetime import datetime def main(): latest_year = '2000' + saw_file = False proc = subprocess.Popen('support/git-set-file-times --list'.split(), stdout=subprocess.PIPE, encoding='utf-8') for line in proc.stdout: @@ -17,9 +18,13 @@ def main(): print("Failed to parse line from git-set-file-times:", line) sys.exit(1) m = argparse.Namespace(**m.groupdict()) + saw_file = True if m.year > latest_year: latest_year = m.year proc.communicate() + if proc.returncode != 0 or not saw_file: + print("support/git-set-file-times failed", file=sys.stderr) + sys.exit(1) fn = 'latest-year.h' with open(fn, 'r', encoding='utf-8') as fh: diff --git a/rsync-web/download.html b/rsync-web/download.html index 5bf47dea..e8ba6e96 100644 --- a/rsync-web/download.html +++ b/rsync-web/download.html @@ -44,16 +44,18 @@ Once you have the source, read the INSTALL.md file for details on some development libraries that you will need to build it. -

The GPG Signing Key

+

Release signing keys

-The GPG signing key that is used to sign the release files is available from the public pgp key-server -network. If you have automatic key-fetching enabled, just running a normal -"gpg --verify" will grab my key automatically. -Or, feel free to grab the gpp - key for Wayne Davison manually.

+

Release files include detached OpenPGP signatures. Releases from 3.5.1 +onward are signed by Zen Dodd using fingerprint +C0E1 0545 0570 4F75 7D92 5AB2 6A64 E8AA 2EFB CF5D. -From 3.4.0 and later releases will be signed by Andrew -Tridgell. Please fetch the key for andrew@tridgell.net from https://keys.openpgp.org/ +

Releases from 3.4.0 through 3.5.0 were signed by Andrew Tridgell using +fingerprint +9FEF 112D CE19 A0DC 7E88 2CB8 1BB2 4997 A853 5F6F. +Earlier releases were signed by +Wayne Davison; his public key +is also available directly.

Binaries

diff --git a/rsync-web/index.html b/rsync-web/index.html index 5352d981..7a7d3179 100644 --- a/rsync-web/index.html +++ b/rsync-web/index.html @@ -16,7 +16,7 @@ rsync is an open source utility that provides fast incremental file transfer. rsync is freely available under the GNU General Public License and is currently being maintained by -Andrew Tridgell. +the rsync project.

A full changelog of all the releases, including upcoming releases, is in the NEWS file. @@ -26,6 +26,28 @@ License and is currently being maintained by +

Rsync version 3.5.2 released

+4 November 2026 + +

Rsync version 3.5.2 has been released. This release fixes +CVE-2026-119741 and other issues found since +3.5.1 and adds process-priority controls for local and remote transfers. + +

See the 3.5.2 NEWS for a detailed changelog. +The latest manpages are also available for:

+ +

The source tar is available here: +rsync-3.5.2.tar.gz +(signature), +and the diffs from version 3.5.1 are available here: +rsync-3.5.1-3.5.2.diffs.gz +(signature). +

Rsync version 3.5.1 released

September 21st, 2026 diff --git a/rsync-web/security.html b/rsync-web/security.html index db8de22d..5da069f2 100644 --- a/rsync-web/security.html +++ b/rsync-web/security.html @@ -12,7 +12,7 @@ h3 { margin-bottom: 0px; }

Rsync Security Advisories

-

If you are running an rsync older than 3.5.0, you should upgrade. 3.5.0 +

If you are running an rsync older than 3.5.2, you should upgrade. 3.5.2 fixes every issue listed on this page.

If you cannot upgrade, the advisories below give the affected versions and @@ -20,6 +20,21 @@ the conditions each issue needs. Several are only reachable in a non-default configuration, and some of the older ones can be mitigated from rsyncd.conf without an upgrade. +


+

Security fix in 3.5.2

+4 November 2026 + +

Rsync 3.5.2 bounds sender-side checksum work that remained after the +CVE-2026-70453 mitigation. Versions through 3.5.1 are affected. + +

    +
  • CVE-2026-119741 +(HIGH) — a malicious receiver can supply weak checksums which collide +with consecutive rolling windows and make the sender calculate a full strong +checksum at every byte offset. Repeating this across public daemon connections +can exhaust host CPU. +
+


Security fixes in 3.5.0

August 13th, 2026 diff --git a/version.h b/version.h index 8d292b01..cae750e1 100644 --- a/version.h +++ b/version.h @@ -1,2 +1,2 @@ -#define RSYNC_VERSION "3.5.1" -#define MAINTAINER_TZ_OFFSET 10.0 +#define RSYNC_VERSION "3.5.2" +#define MAINTAINER_TZ_OFFSET 11.0