diff --git a/.gitattributes b/.gitattributes index 17c3856c4..9b57805eb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,10 +8,11 @@ /rsync-web/ export-ignore # old_versions/ holds static binaries of historical rsync releases, used by the -# version-mixing test suite (.github/workflows/ubuntu-version-mix.yml) to run +# version-mixing test suite (.github/workflows/ci.yml) to run # the current code against a real old peer over the daemon / remote-shell. # Mark the binaries as binary so the `text=auto eol=lf` rule above can't try to # normalise line endings and corrupt them; export-ignore keeps them out of the # release source tarball. /old_versions/rsync_* binary /old_versions/rsync_* export-ignore +/old_versions/binaries.json export-ignore diff --git a/.github/actions/interoperability/action.yml b/.github/actions/interoperability/action.yml new file mode 100644 index 000000000..35ba95886 --- /dev/null +++ b/.github/actions/interoperability/action.yml @@ -0,0 +1,160 @@ +name: Directed transfers +description: Native SSH and daemon transfers between seven endpoints +inputs: + platform: + required: true + description: Native endpoint + guest: + default: 'false' + description: Use the existing local VM SSH connection +runs: + using: composite + steps: + - name: Install Tailscale + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py install "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Generate enrolment recipient + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py recipient "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload recipient + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-public-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/public-key.json + if-no-files-found: error + retention-days: 7 + - name: Join quarantine + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py connect "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Start native fixture services + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py prepare "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload endpoint identity + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-ready-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/ready.json + if-no-files-found: error + retention-days: 7 + - name: Check quarantine + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py quarantine "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload quarantine result + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-quarantine-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/quarantine.json + if-no-files-found: error + retention-days: 7 + - name: Check admission + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py admit "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload endpoint admission + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-admitted-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/admitted.json + if-no-files-found: error + retention-days: 7 + - name: Initial transfers + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py transfer1 "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload initial transfer completion + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-transfer1-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/transfer1.json + if-no-files-found: error + retention-days: 7 + - name: Verify initial data and update sources + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py next "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload update readiness + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-next-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/next.json + if-no-files-found: error + retention-days: 7 + - name: Checksum updates and deletion + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py transfer2 "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload update completion + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-transfer2-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/transfer2.json + if-no-files-found: error + retention-days: 7 + - name: Verify updated native data + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py verify "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload transfer result + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-result-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/result.json + if-no-files-found: error + retention-days: 7 + - name: Check revocation + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py revoked "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" + - name: Upload revocation result + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: interoperability-${{ github.run_attempt }}-revoked-${{ inputs.platform }} + path: ${{ runner.temp }}/interoperability/revoked.json + if-no-files-found: error + retention-days: 7 + - name: Stop fixture services and leave the network + if: always() + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INTEROP_PLATFORM: ${{ inputs.platform }} + INTEROP_GUEST: ${{ inputs.guest }} + run: python3 -B -S .github/scripts/interop-host.py stop "$INTEROP_PLATFORM" --root "$RUNNER_TEMP/interoperability" diff --git a/.github/scripts/android-emulator.py b/.github/scripts/android-emulator.py new file mode 100644 index 000000000..414c510da --- /dev/null +++ b/.github/scripts/android-emulator.py @@ -0,0 +1,178 @@ +import argparse +from contextlib import redirect_stdout +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import time + +from android import check_transfer as transfer, quote_command, run + + +def wait_for_boot(adb, proc, timeout=300): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError('Emulator exited before boot completed') + try: + if adb('shell', 'getprop sys.boot_completed', capture_output=True, timeout=10).stdout.strip() == '1': + return + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + time.sleep(2) + raise TimeoutError('Android boot did not complete') + + +def stop_emulator(adb, proc): + if proc.poll() is not None: + return + try: + adb('emu', 'kill', timeout=10, capture_output=True) + proc.wait(timeout=20) + return + except subprocess.SubprocessError: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) + + +def check_device(adb): + values = {} + commands = {'api': ('getprop', 'ro.build.version.sdk'), + 'abi': ('getprop', 'ro.product.cpu.abi'), + 'emulator': ('getprop', 'ro.kernel.qemu'), + 'selinux': ('getenforce',), 'uid': ('id', '-u'), + 'kernel': ('uname', '-r'), 'fingerprint': ('getprop', 'ro.build.fingerprint')} + for name, command in commands.items(): + values[name] = adb('shell', quote_command(command), capture_output=True).stdout.strip() + for name, expected in dict(api='36', abi='x86_64', emulator='1', selinux='Enforcing', uid='2000').items(): + if values[name] != expected: + raise ValueError(f'Android {name}: expected {expected}, got {values[name]!r}') + print(json.dumps(values, indent=2), flush=True) + return values + + +def check_transfer(adb, binary, output): + device = check_device(adb) + work = adb('shell', 'mktemp -d /data/local/tmp/rsync-ci.XXXXXXXX', capture_output=True).stdout.strip() + if not re.fullmatch(r'/data/local/tmp/rsync-ci\.[A-Za-z0-9]{8}', work): + raise ValueError('Invalid Android scratch directory') + try: + adb('push', str(binary), f'{work}/{binary.name}', timeout=60) + digest = hashlib.sha256(binary.read_bytes()).hexdigest() + remote = adb('shell', quote_command(['sha256sum', f'{work}/{binary.name}']), + capture_output=True).stdout.split()[0] + if digest != remote: + raise ValueError('Android binary hash does not match the candidate') + adb('shell', quote_command(['chmod', '700', f'{work}/{binary.name}'])) + def command(*args, **kwargs): + return adb('shell', '-T', quote_command(args), **kwargs) + with tempfile.TemporaryDirectory(prefix='rsync-android-data-') as directory: + local = Path(directory) / 'data' + def write_file(path, data): + local.write_bytes(data) + adb('push', str(local), path, capture_output=True) + def read_file(path): + adb('pull', path, str(local), capture_output=True) + return local.read_bytes() + with (output / 'transfer.log').open('w') as log, redirect_stdout(log): + transfer(command, f'{work}/{binary.name}', work, hardlinks=False, + write_file=write_file, read_file=read_file) + check_permissions(adb, binary, work, output) + receipt = {'device': device, 'binary_sha256': digest, + 'source': run('git', 'rev-parse', 'HEAD', capture_output=True).stdout.strip()} + (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + finally: + failed = sys.exc_info()[0] is not None + try: + adb('shell', quote_command(['rm', '-rf', work])) + except (OSError, subprocess.SubprocessError) as error: + if not failed: + raise + print(f'Android scratch cleanup failed: {error}', file=sys.stderr) + + +def check_permissions(adb, binary, work, output): + with tempfile.TemporaryDirectory(prefix='rsync-unreadable-') as directory: + private = Path(directory) / 'private' + private.write_text('must not be copied\n') + adb('shell', quote_command(['mkdir', f'{work}/unreadable', f'{work}/refused'])) + adb('push', str(private), f'{work}/unreadable/private') + adb('shell', quote_command(['chmod', '000', f'{work}/unreadable/private'])) + result = adb('shell', quote_command([f'{work}/{binary.name}', '-r', f'{work}/unreadable/', + f'{work}/refused/']), capture_output=True, check=False) + (output / 'permissions.log').write_text(result.stdout + result.stderr) + if result.returncode != 23 or 'Permission denied' not in result.stderr: + raise ValueError('Unreadable Android source did not fail with permission denied and exit 23') + adb('shell', quote_command(['test', '!', '-e', f'{work}/refused/private'])) + adb('shell', 'test -d /data/system') + adb('shell', 'test ! -r /data/system') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--binary', type=Path, default=Path('rsync')) + parser.add_argument('--output', type=Path, default=Path('test-results/android-emulator')) + args = parser.parse_args() + binary = args.binary.resolve(strict=True) + args.output.mkdir(parents=True) + sdk = Path(os.environ['ANDROID_HOME']) + os.environ['PATH'] = os.pathsep.join(str(sdk / path) for path in ( + 'platform-tools', 'emulator', 'cmdline-tools/latest/bin')) + os.pathsep + os.environ.get('PATH', '') + image = sdk / 'system-images/android-36/google_apis/x86_64/source.properties' + properties = image.read_text() + if 'Pkg.Revision=7' not in properties.splitlines(): + raise ValueError('Expected Android API 36 Google APIs X86-64 image revision 7') + (args.output / 'image.properties').write_text(properties) + emulator = shutil.which('emulator') + if not emulator: + raise ValueError('Android emulator is not on PATH') + version = run(emulator, '-version', capture_output=True, check=False) + (args.output / 'emulator-version.log').write_text(version.stdout + version.stderr) + if version.returncode: + raise ValueError(f'Emulator version probe failed ({version.returncode}): {version.stdout}{version.stderr}') + run(emulator, '-accel-check') + serial = 'emulator-5560' + def adb(*command, **kwargs): + return run('adb', '-s', serial, *command, **kwargs) + if serial in run('adb', 'devices', capture_output=True).stdout.split(): + raise ValueError(f'{serial} is already in use') + def cancel(signum, frame): + raise SystemExit(128 + signum) + previous = {sig: signal.signal(sig, cancel) for sig in (signal.SIGINT, signal.SIGTERM)} + try: + with tempfile.TemporaryDirectory(prefix='rsync-avd-') as directory: + env = {**os.environ, 'ANDROID_AVD_HOME': directory} + run('avdmanager', 'create', 'avd', '--name', 'rsync', '--package', + 'system-images;android-36;google_apis;x86_64', input='no\n', env=env, timeout=60) + with (args.output / 'emulator.log').open('w') as log: + proc = subprocess.Popen([emulator, '-avd', 'rsync', '-port', '5560', '-accel', 'on', + '-no-window', '-no-audio', '-no-boot-anim', '-no-snapshot', + '-gpu', 'swiftshader', '-memory', '2048', '-cores', '2'], + env=env, stdout=log, stderr=subprocess.STDOUT) + try: + wait_for_boot(adb, proc) + check_transfer(adb, binary, args.output) + finally: + stop_emulator(adb, proc) + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + print((args.output / 'transfer.log').read_text(), end='') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(f'Android emulator check failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/android.py b/.github/scripts/android.py new file mode 100644 index 000000000..e35b44fc7 --- /dev/null +++ b/.github/scripts/android.py @@ -0,0 +1,173 @@ +import argparse +import hashlib +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +def quote_command(arguments): + return ' '.join(map(shlex.quote, arguments)) + + +def run(*command, timeout=30, **kwargs): + kwargs.setdefault('check', True) + kwargs.setdefault('text', True) + return subprocess.run(command, timeout=timeout, **kwargs) + + +def check_transfer(command, binary, work, executor=(), hardlinks=True, write_file=None, read_file=None): + source, destination = f'{work}/source', f'{work}/destination' + data_path, delta_path = f'{source}/dir/data', f'{source}/dir/delta' + data = b'initial data\n' + delta = b''.join(f'{i:08d}:0123456789abcdef0123456789abcdef\n'.encode() for i in range(8192)) + + if write_file is None: + def write_file(path, data): + command('tee', path, input=data, text=False, stdout=subprocess.DEVNULL) + if read_file is None: + def read_file(path): + return command('cat', path, capture_output=True, text=False).stdout + + def stat(path, field): + return command('stat', '-c', field, path, capture_output=True).stdout.strip() + + command('mkdir', '-p', f'{source}/dir', destination) + write_file(data_path, data) + write_file(delta_path, delta) + command('chmod', '640', data_path) + linked = command('ln', data_path, f'{source}/dir/hard-link', capture_output=True, check=False) + if hardlinks: + linked.check_returncode() + elif linked.returncode == 0 or 'Permission denied' not in linked.stderr: + raise ValueError('Expected Android shell hard-link creation to be denied') + command('ln', '-s', 'data', f'{source}/dir/symbolic-link') + write_file(f'{source}/obsolete', b'remove me\n') + print(command(*executor, binary, '--version', capture_output=True).stdout.splitlines()[0], flush=True) + for updated in (False, True): + options = ['-aH'] + if updated: + data = b'updated Android data\n' + change = b'changed delta block' + delta = delta[:65536] + change + delta[65536 + len(change):] + write_file(data_path, data) + write_file(delta_path, delta) + command('chmod', '600', data_path) + command('rm', f'{source}/obsolete') + options += ['--delete', '--no-whole-file', '--stats'] + command('touch', '-t', '200201020304.05' if updated else '200101020304.05', data_path, delta_path) + result = command('env', 'LC_ALL=C', *executor, binary, *options, source + '/', destination + '/', + capture_output=True, check=False, timeout=120) + if result.returncode: + raise ValueError(f'Android transfer failed ({result.returncode}): {result.stdout}{result.stderr}') + for name, expected in (('data', data), ('delta', delta)): + path = f'{destination}/dir/{name}' + received = read_file(path) + if received != expected: + raise ValueError(f'Android transfer changed {name}: expected {expected[:32]!r}, got {received[:32]!r}') + if stat(f'{source}/dir/{name}', '%Y') != stat(path, '%Y'): + raise ValueError(f'Android transfer lost the {name} timestamp') + if stat(f'{destination}/dir/data', '%a') != ('600' if updated else '640'): + raise ValueError('Android transfer lost file permissions') + if hardlinks and stat(f'{destination}/dir/data', '%i') != stat(f'{destination}/dir/hard-link', '%i'): + raise ValueError('Android transfer lost a hard link') + if command('readlink', f'{destination}/dir/symbolic-link', capture_output=True).stdout.strip() != 'data': + raise ValueError('Android transfer changed a symlink') + if updated: + command('test', '!', '-e', f'{destination}/obsolete') + counts = {} + for name in ('Matched', 'Literal'): + match = re.search(rf'^{name} data: ([0-9,]+) bytes$', result.stdout, re.MULTILINE) + if not match: + raise ValueError(f'Missing Android {name.lower()} data statistics: {result.stdout}') + counts[name] = int(match[1].replace(',', '')) + if counts['Matched'] <= 0 or not 0 < counts['Literal'] < len(delta): + raise ValueError(f'Android update did not use the delta algorithm: {counts}') + print(f'Delta: {counts["Matched"]} matched bytes, {counts["Literal"]} literal bytes', flush=True) + print('Android transfer passed', flush=True) + + +def test_termux(): + image = os.environ['TERMUX_IMAGE'] + run('docker', 'pull', image, timeout=300) + platform = run('docker', 'image', 'inspect', '--format', '{{.Os}}/{{.Architecture}}', image, + capture_output=True).stdout.strip() + if platform != 'linux/amd64': + raise ValueError(f'Unexpected Termux image platform: {platform}') + container = run('docker', 'run', '--detach', '--rm', '--network', 'none', '--user', '1000:1000', + '--cap-drop', 'ALL', '--pids-limit', '256', '--memory', '1g', + '--security-opt', 'no-new-privileges', '--mount', + f'type=bind,source={Path("rsync").resolve()},target=/workspace/rsync,readonly', + image, 'sleep', '300', capture_output=True).stdout.strip() + try: + def command(*args, **kwargs): + return run('docker', 'exec', '-i', container, *args, **kwargs) + work = command('mktemp', '-d', capture_output=True).stdout.strip() + check_transfer(command, '/workspace/rsync', work) + finally: + run('docker', 'rm', '--force', container) + + +def restore_binary(directory, abi, destination): + binaries = list(directory.glob(f'rsync-*-android-{abi}')) + if len(binaries) != 1: + raise ValueError('Expected one Android binary') + binary = binaries[0] + data = binary.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if binary.with_name(binary.name + '.sha256').read_text() != f'{digest} {binary.name}\n': + raise ValueError('Android binary checksum does not match') + with destination.open('xb') as stream: + stream.write(data) + destination.chmod(0o755) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('task', choices=['test', 'termux', 'package', 'restore']) + parser.add_argument('--executor') + parser.add_argument('--abi', choices=['arm64-v8a', 'armeabi-v7a', 'x86_64']) + args = parser.parse_args() + if args.task == 'termux': + test_termux() + return 0 + if args.task == 'test': + description = subprocess.check_output(['file', 'rsync'], text=True, timeout=30) + print(description, end='') + if 'statically linked' not in description or 'dynamically linked' in description: + raise ValueError('Android binary is not statically linked') + if not args.executor: + parser.error('--executor is required for Bionic testing') + executor = [] if args.executor == 'direct' else [args.executor] + with tempfile.TemporaryDirectory(prefix='rsync-android-') as work: + check_transfer(run, str(Path('rsync').resolve()), work, executor) + return 0 + if not args.abi: + parser.error('--abi is required for packaging or restoring a binary') + if args.task == 'restore': + restore_binary(Path('dist'), args.abi, Path('rsync')) + return 0 + match = re.search(r'RSYNC_VERSION\s+"([A-Za-z0-9.+-]+)"', Path('version.h').read_text()) + if not match: + raise ValueError('Cannot determine the rsync version') + name = f'rsync-{match[1]}-android-{args.abi}' + output = Path('dist') + output.mkdir(exist_ok=True) + binary = output / name + shutil.copy2('rsync', binary) + digest = hashlib.sha256(binary.read_bytes()).hexdigest() + (output / f'{name}.sha256').write_text(f'{digest} {name}\n') + with open(os.environ['GITHUB_ENV'], 'a') as stream: + stream.write(f'ARTIFACT_NAME=rsync-android-{args.abi}\n') + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Android check failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/archive.py b/.github/scripts/archive.py new file mode 100644 index 000000000..d2e800de3 --- /dev/null +++ b/.github/scripts/archive.py @@ -0,0 +1,173 @@ +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'packaging')) +from source_archive import GEN_FILES, make_archive + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, timeout=900, **kwargs) + + +def create(archive): + version = re.search(r'#define RSYNC_VERSION "([A-Za-z0-9.+-]+)"', Path('version.h').read_text()) + if not version: + raise ValueError('Cannot read release version') + with tempfile.TemporaryDirectory(prefix='rsync-release-') as directory: + stage = Path(directory) / ('rsync-' + version[1]) + stage.mkdir() + for name in GEN_FILES: + shutil.copy2(name, stage / name) + make_archive('HEAD', stage, archive) + + +def qualify(archive, output): + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + with tempfile.TemporaryDirectory(prefix='rsync-archive-test-') as directory: + root = Path(directory) + root.chmod(0o755) + with tarfile.open(archive) as source: + members = source.getmembers() + tops = {member.name.split('/')[0] for member in members} + if len(tops) != 1 or not next(iter(tops)).startswith('rsync-'): + raise ValueError('Invalid release archive root') + if any(member.uid or member.gid for member in members): + raise ValueError('Release archive contains non-root ownership') + release_root = root / next(iter(tops)) + for member in members: + parts = PurePosixPath(member.name).parts + if PurePosixPath(member.name).is_absolute() or '..' in parts or '.git' in parts or '.github' in parts: + raise ValueError('Invalid release archive entry') + path = (root / member.name).resolve() + if path != release_root and release_root not in path.parents: + raise ValueError('Archive entry escapes extraction directory') + if member.isdir(): + path.mkdir(parents=True, exist_ok=True) + elif member.isfile(): + path.parent.mkdir(parents=True, exist_ok=True) + with source.extractfile(member) as incoming, path.open('xb') as target: + shutil.copyfileobj(incoming, target) + elif member.issym() or member.islnk(): + target = ((path.parent if member.issym() else root) / member.linkname).resolve() + if target != release_root and release_root not in target.parents: + raise ValueError('Archive link escapes extraction directory') + if member.issym(): + path.symlink_to(member.linkname) + else: + os.link(target, path) + else: + raise ValueError('Unsupported release archive entry') + if not member.issym(): + path.chmod(member.mode) + os.utime(path, (member.mtime, member.mtime), follow_symlinks=False) + source = root / tops.pop() + for name in GEN_FILES: + if not (source / name).is_file() or not (source / name).stat().st_size: + raise ValueError(f'Missing generated release file: {name}') + for name in ('configure', 'configure.sh', 'rsync-ssl', 'support/rrsync'): + if not os.access(source / name, os.X_OK): + raise ValueError(f'Release file is not executable: {name}') + blocked = root / 'blocked' + blocked.mkdir() + for name in ('git', 'autoconf', 'autoheader', 'automake', 'aclocal'): + tool = blocked / name + tool.write_text(f'#!{sys.executable}\nraise SystemExit("Unexpected build dependency: {name}")\n') + tool.chmod(0o755) + env = {**os.environ, 'PATH': str(blocked) + os.pathsep + os.environ['PATH'], + 'RSYNC_TEST_TMP': '/dev/shm'} + build, prefix = root / 'build', root / 'installed' + build.mkdir() + with (output / 'build.log').open('w') as log: + run(str(source / 'configure'), '--with-rrsync', '--disable-md2man', '--prefix=' + str(prefix), + cwd=build, env=env, stdout=log, stderr=subprocess.STDOUT) + run('make', '-j2', 'check-progs', cwd=build, env=env, stdout=log, stderr=subprocess.STDOUT) + privilege = 'root' if os.geteuid() == 0 else 'nonroot' + for transport in ('pipe', 'tcp'): + profiles = f'non-asan,{privilege},self-peer,linux,no-userns,archive' + args = [] if transport == 'pipe' else ['--use-tcp', '--daemon-tests-only'] + if transport == 'pipe': + profiles = 'pipe,' + profiles + results = root / 'results' / transport + try: + with (output / f'{transport}.log').open('w') as log: + run(sys.executable, '-B', '-S', str(source / 'testsuite/tools/check.py'), '.', './rsync', + profiles, str(results), *args, '-j4', cwd=build, env=env, + stdout=log, stderr=subprocess.STDOUT) + finally: + retained = output / transport + retained.mkdir() + for name in ('receipt.json', 'run.log'): + if (results / name).is_file(): + shutil.copy2(results / name, retained / name) + run('make', 'install', cwd=build, env=env) + required = ('bin/rsync', 'bin/rsync-ssl', 'bin/rrsync', 'share/man/man1/rsync.1', + 'share/man/man1/rsync-ssl.1', 'share/man/man1/rrsync.1', 'share/man/man5/rsyncd.conf.5') + for name in required: + if not (prefix / name).is_file() or not (prefix / name).stat().st_size: + raise ValueError(f'Missing installed release file: {name}') + data = root / 'data' + data.mkdir() + (data / 'file').write_bytes(b'installed rsync\n') + destination = root / 'copy' + run(str(prefix / 'bin/rsync'), '-a', str(data) + '/', str(destination)) + if (destination / 'file').read_bytes() != (data / 'file').read_bytes(): + raise ValueError('Installed rsync changed file contents') + rrsync = prefix / 'bin/rrsync' + configured, count = re.subn(r'(?m)^RSYNC = .+$', + lambda match: f'RSYNC = {str(prefix / "bin/rsync")!r}', + rrsync.read_text(), count=1) + if count != 1: + raise ValueError('Cannot configure installed rrsync executable') + rrsync.write_text(configured) + remote = root / 'remote' + remote.write_text(f'#!{sys.executable}\nimport os, shlex, sys\n' + 'os.environ["SSH_ORIGINAL_COMMAND"] = " ".join(map(shlex.quote, sys.argv[2:]))\n' + f'os.execv({str(prefix / "bin/rrsync")!r}, ["rrsync", {str(data)!r}])\n') + remote.chmod(0o755) + installed_env = {**env, 'PATH': str(prefix / 'bin') + os.pathsep + env['PATH']} + restricted = root / 'restricted' + run(str(prefix / 'bin/rsync'), '-a', '-e', str(remote), 'dummy:file', str(restricted), env=installed_env) + if restricted.read_bytes() != (data / 'file').read_bytes(): + raise ValueError('Installed rrsync changed file contents') + run(sys.executable, '-B', '-S', str(source / 'testsuite/tools/check-rsync-ssl.py'), + '--rsync', str(prefix / 'bin/rsync'), '--rsync-ssl', str(prefix / 'bin/rsync-ssl'), + '--openssl', shutil.which('openssl'), env=env) + sentinel = prefix / 'unrelated' + sentinel.write_text('retained\n') + run('make', 'uninstall', cwd=build, env=env) + remaining = {path.relative_to(prefix).as_posix() for path in prefix.rglob('*') + if path.is_file() or path.is_symlink()} + if remaining != {'unrelated'} or sentinel.read_text() != 'retained\n': + raise ValueError(f'Incorrect uninstall result: {remaining}') + (output / 'qualification.json').write_text(json.dumps({'archive_sha256': digest, 'result': 'pass'}, indent=2) + '\n') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('task', choices=('create', 'test')) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + archive = args.archive.resolve() + if args.task == 'create': + archive.parent.mkdir(parents=True, exist_ok=True) + create(archive) + else: + if args.output is None: + parser.error('--output is required for testing') + output = args.output.resolve() + output.mkdir(parents=True) + qualify(archive, output) + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/check-platform.py b/.github/scripts/check-platform.py new file mode 100644 index 000000000..85ff566f2 --- /dev/null +++ b/.github/scripts/check-platform.py @@ -0,0 +1,44 @@ +import argparse +from pathlib import Path +import platform +import shlex +import sys + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('system', choices=['ubuntu', 'almalinux', 'macos', 'cygwin']) + parser.add_argument('--release') + parser.add_argument('--arch', required=True) + args = parser.parse_args() + print(platform.platform()) + if platform.machine() != args.arch: + raise ValueError(f'Expected {args.arch}, got {platform.machine()}') + if args.system in ('ubuntu', 'almalinux'): + values = {} + for line in Path('/etc/os-release').read_text().splitlines(): + name, separator, value = line.partition('=') + if separator and name in ('ID', 'VERSION_ID'): + parts = shlex.split(value) + if len(parts) != 1: + raise ValueError(f'Invalid {name} in os-release') + values[name] = parts[0] + release = values['VERSION_ID'] + if args.system == 'almalinux': + release = release.split('.')[0] + if values['ID'] != args.system or release != args.release: + raise ValueError(f'Unexpected Linux identity: {values}') + elif args.system == 'macos': + if platform.system() != 'Darwin' or platform.mac_ver()[0].split('.')[0] != args.release: + raise ValueError('Unexpected macOS identity') + elif not platform.system().startswith('CYGWIN'): + raise ValueError('Expected Cygwin') + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (KeyError, OSError, ValueError) as error: + print(f'Platform check failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/check-results.py b/.github/scripts/check-results.py new file mode 100644 index 000000000..c107b7edd --- /dev/null +++ b/.github/scripts/check-results.py @@ -0,0 +1,57 @@ +import json +import os +import sys + + +def check(data, expected): + def require(condition, message): + if not condition: + raise ValueError(message) + + require(isinstance(data, dict) and data.keys() == expected.keys(), + 'Missing or unexpected job families') + require(all(value in ('true', 'false') for value in expected.values()) + and 'true' in expected.values(), 'No valid job selection') + messages = [] + for family, group in data.items(): + require(isinstance(group, dict), f'{family}: invalid job results') + result = group.get('result') + if expected[family] == 'false': + require(result == 'skipped', f'{family}: expected skipped') + messages.append(f'{family}: skipped') + continue + require(result == 'success', f'{family}: not successful') + outputs = group.get('outputs', {}) + require(isinstance(outputs, dict), f'{family}: invalid outputs') + jobs = json.loads(outputs.get('jobs', 'null')) + inputs = json.loads(outputs.get('inputs', '{}')) + require(isinstance(jobs, dict) and jobs, f'{family}: missing job results') + require(isinstance(inputs, dict) and all(type(value) is bool for value in inputs.values()), + f'{family}: invalid job selections') + require(inputs.keys() <= jobs.keys(), f'{family}: unknown job selection') + require(any(inputs.get(name) is not False for name in jobs), f'{family}: no jobs selected') + for name, job in jobs.items(): + require(isinstance(job, dict), f'{family}/{name}: invalid job result') + result = job.get('result') + if inputs.get(name) is False: + require(result == 'skipped', f'{family}/{name}: expected skipped') + else: + require(result == 'success', f'{family}/{name}: {result or "missing result"}') + messages.append(f'{family}/{name}: {result}') + return messages + + +def main(): + try: + expected = {name[len('CI_EXPECT_'):].lower(): value for name, value in os.environ.items() + if name.startswith('CI_EXPECT_')} + messages = check(json.loads(os.environ['CI_RESULTS']), expected) + except (KeyError, TypeError, ValueError) as error: + print(f'CI result check failed: {error}', file=sys.stderr) + return 1 + print('\n'.join(messages)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/diagnostics.py b/.github/scripts/diagnostics.py new file mode 100644 index 000000000..aa6151a32 --- /dev/null +++ b/.github/scripts/diagnostics.py @@ -0,0 +1,148 @@ +import argparse +import hashlib +import io +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import tarfile +import time +import urllib.error +import urllib.request + + +def logged(command, path): + with path.open('w') as log, subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors='replace') as proc: + def cancel(signum, frame): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + raise SystemExit(128 + signum) + previous = {sig: signal.signal(sig, cancel) for sig in (signal.SIGTERM, signal.SIGINT)} + try: + for line in proc.stdout: + log.write(line) + print(line, end='', flush=True) + return proc.wait() + finally: + proc.stdout.close() + for sig, handler in previous.items(): + signal.signal(sig, handler) + + +def summary(title, path, pattern): + lines = [] + if path.is_file(): + with path.open(errors='replace') as stream: + lines = [line.rstrip() for line in stream if re.search(pattern, line)] + text = f'## {title}\n\n```text\n' + '\n'.join(lines or ['No summary produced']) + '\n```\n' + print(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as stream: + stream.write(text) + + +def install_actionlint(): + name = 'actionlint_1.7.12_linux_amd64.tar.gz' + url = f'https://github.com/rhysd/actionlint/releases/download/v1.7.12/{name}' + for attempt in range(5): + try: + with urllib.request.urlopen(url, timeout=60) as response: + data = response.read(32 * 1024 * 1024 + 1) + break + except (OSError, urllib.error.URLError): + if attempt == 4: + raise + time.sleep(3 * (attempt + 1)) + if hashlib.sha256(data).hexdigest() != '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8': + raise ValueError('actionlint SHA-256 mismatch') + with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive: + member = archive.getmember('actionlint') + if not member.isfile(): + raise ValueError('actionlint archive has no regular executable') + with archive.extractfile(member) as source, Path('actionlint').open('wb') as destination: + shutil.copyfileobj(source, destination) + Path('actionlint').chmod(0o755) + with open(os.environ['GITHUB_PATH'], 'a') as stream: + stream.write(str(Path.cwd()) + '\n') + + +def main(): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest='task', required=True) + sub.add_parser('install-actionlint') + coverage = sub.add_parser('coverage') + coverage.add_argument('transport', choices=['pipe', 'tcp']) + sub.add_parser('coverage-summary') + scan = sub.add_parser('scan') + scan.add_argument('tool', choices=['scan-build-18', 'scan-build-21']) + for name in ('valgrind', 'valgrind-results'): + command = sub.add_parser(name) + command.add_argument('--privilege', choices=['root', 'non-root'], required=True) + command.add_argument('--transport', choices=['pipe', 'tcp'], required=True) + command.add_argument('--profiles') + args = parser.parse_args() + if args.task.startswith('valgrind') and args.privilege == 'root' and os.geteuid() != 0: + return subprocess.call(['sudo', '-E', sys.executable, '-S', __file__, *sys.argv[1:]]) + if args.task == 'install-actionlint': + install_actionlint() + return 0 + if args.task == 'coverage': + target = 'coverage' if args.transport == 'pipe' else 'coverage-tcp' + return logged(['make', target], Path(f'cov-{args.transport}.log')) + if args.task == 'coverage-summary': + for transport in ('pipe', 'tcp'): + summary(f'Coverage ({transport})', Path(f'cov-{transport}.log'), r'^(lines|functions|branches|decisions):') + return 0 + if args.task == 'scan': + command = [args.tool, '--status-bugs', '-o', str(Path('scan-report').resolve()), + 'make', 'check-progs', f'-j{len(os.sched_getaffinity(0))}'] + result = logged(command, Path('scan-build.out')) + summary(args.tool, Path('scan-build.out'), r'scan-build: .* bugs? found|scan-build: No bugs found') + return result + output = Path(os.environ['VALGRIND_SCRATCH']) + evidence = Path('test-results') + evidence.mkdir(exist_ok=True) + if args.task == 'valgrind': + if not args.profiles: + parser.error('--profiles is required for Valgrind tests') + options = ['--use-tcp', '--daemon-tests-only'] if args.transport == 'tcp' else [] + command = [sys.executable, '-S', 'testsuite/tools/check.py', '.', './rsync', args.profiles, str(output), + '--valgrind', '--valgrind-opts=--leak-check=no --error-exitcode=0', + *options, '-j2', '--timeout=600', '--preserve-scratch'] + result = logged(command, evidence / f'valgrind-{args.privilege}-{args.transport}.log') + if (output / 'receipt.json').is_file(): + shutil.copyfile(output / 'receipt.json', evidence / f'valgrind-{args.privilege}-{args.transport}.json') + return result + logs = sorted((output / 'scratch' / 'testtmp' / 'valgrind-logs').glob('valgrind.*.log')) + if not logs: + raise ValueError('No Valgrind logs were produced') + destination = evidence / 'valgrind-logs' + destination.mkdir(exist_ok=True) + failed = [] + for path in logs: + shutil.copyfile(path, destination / path.name) + with path.open(errors='replace') as stream: + if any(re.search(r'ERROR SUMMARY: [1-9][0-9]* errors', line) for line in stream): + failed.append(path.name) + print(f'Scanned {len(logs)} Valgrind logs') + if failed: + raise ValueError('Valgrind reported errors: ' + ', '.join(failed)) + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Diagnostic check failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/guest.py b/.github/scripts/guest.py new file mode 100644 index 000000000..ca17b1e8c --- /dev/null +++ b/.github/scripts/guest.py @@ -0,0 +1,202 @@ +import argparse +import json +import os +from pathlib import Path +import platform +import pwd +import shutil +import signal +import struct +import subprocess +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('platform', choices=['freebsd', 'netbsd', 'openbsd', 'solaris', 'alpine', 'check']) + parser.add_argument('--release') + parser.add_argument('--output', type=Path, default=Path('test-results')) + args = parser.parse_args() + if args.platform == 'check': + try: + with (args.output / 'status').open('rb') as stream: + status = stream.read(3) + except OSError as error: + print(f'Guest results unavailable: {error.strerror}', file=sys.stderr) + return 1 + if status not in (b'0', b'0\n'): + print('Guest checks failed or produced an invalid status', file=sys.stderr) + return 1 + return 0 + + os.umask(0o022) + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + status = 1 + active = None + cross_device = None + mounted = False + source = None + env = {**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'} + + def cancel(signum, frame): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + if active is not None and active.poll() is None: + active.terminate() + try: + active.wait(timeout=10) + except subprocess.TimeoutExpired: + active.kill() + active.wait(timeout=5) + raise SystemExit(128 + signum) + + def command(label, argv, *, required=True, destination=None, account=None): + nonlocal active + print(f'::group::{label}', flush=True) + options = {} + current_env = env.copy() + if account: + options = dict(user=account.pw_uid, group=account.pw_gid, + extra_groups=os.getgrouplist(account.pw_name, account.pw_gid)) + current_env['HOME'] = account.pw_dir + stream = None + try: + stream = destination.open('w') if destination else None + active = subprocess.Popen(argv, env=current_env, stdout=stream, **options) + result = active.wait() + if result and required: + raise subprocess.CalledProcessError(result, argv) + return result + except OSError as error: + if required: + raise + print(f'::error::{label}: {error}', file=sys.stderr) + return 2 + finally: + active = None + if stream: + stream.close() + print('::endgroup::', flush=True) + + previous = {sig: signal.signal(sig, cancel) for sig in (signal.SIGTERM, signal.SIGINT)} + try: + if args.platform == 'alpine': + shutil.copytree('/source', '/work', symlinks=True) + os.chdir('/work') + source = Path.cwd() + expected = {'freebsd': 'FreeBSD', 'netbsd': 'NetBSD', 'openbsd': 'OpenBSD', + 'solaris': 'SunOS', 'alpine': 'Linux'}[args.platform] + command('Platform', ['uname', '-a']) + if platform.system() != expected: + raise ValueError(f'Expected {expected}, got {platform.system()}') + if args.platform in ('freebsd', 'netbsd', 'openbsd'): + actual = platform.release() + if platform.machine() != 'amd64' or not args.release or not ( + actual == args.release or args.platform == 'freebsd' and actual.startswith(args.release + '-RELEASE')): + raise ValueError(f'Unexpected guest identity: {actual} {platform.machine()}') + if args.platform == 'alpine': + actual = subprocess.check_output(['apk', '--print-arch'], text=True).strip() + if actual != env['APK_ARCH'] or struct.calcsize('P') * 8 != int(env['BITS']): + raise ValueError('Unexpected Alpine userspace architecture') + + flags = ['--with-rrsync'] + features = [] + profiles = f'non-asan,self-peer,{args.platform}' + if args.platform == 'freebsd': + env.update(CPPFLAGS='-I/usr/local/include', LDFLAGS='-L/usr/local/lib') + flags += ['--disable-md2man', '--enable-iconv', '--enable-acl-support', '--enable-xattr-support', + '--enable-roll-simd', '--enable-roll-asm', '--enable-md5-asm'] + features = ['zstd', 'lz4', 'xxh128', 'xxh3', 'xxh64', 'iconv', 'IDN', 'ACLs', 'xattrs'] + cross_device = Path(tempfile.mkdtemp(prefix='rsync-cross-device.')) + command('Cross-device filesystem', ['mount', '-t', 'tmpfs', '-o', 'size=256m,mode=1777,nosuid', + 'tmpfs', str(cross_device)]) + mounted = True + env['RSYNC_TEST_TMP'] = str(cross_device) + elif args.platform in ('netbsd', 'solaris'): + flags += ['--disable-zstd', '--disable-md2man', '--disable-xxhash', '--disable-lz4', '--disable-idn'] + profiles += ',no-idn,no-xxhash,no-zstd-threads' + elif args.platform == 'openbsd': + env.update(AUTOCONF_VERSION='2.71', AUTOMAKE_VERSION='1.16', + CPPFLAGS='-I/usr/local/include -DXXH_INLINE_ALL=1', LDFLAGS='-L/usr/local/lib', + ac_cv_search_XXH64_createState='', + RSYNC_EXCLUDE='acl-symlink-race,sender-symlink-target-race,sender-remove-source-secure') + flags += ['--disable-md2man', '--enable-iconv'] + features = ['zstd', 'lz4', 'xxh128', 'xxh3', 'xxh64', 'iconv', 'IDN'] + else: + flags += ['--enable-iconv', '--enable-acl-support', '--enable-xattr-support'] + features = ['zstd', 'lz4', 'xxh128', 'xxh3', 'xxh64', 'iconv', 'IDN', 'ACLs', 'xattrs'] + profiles = env['PROFILES'] + command('Configure', ['./configure', *flags]) + command('Build', ['make', *(['-j4'] if args.platform == 'alpine' else []), 'check-progs']) + status = int(bool(command('Version', ['./rsync', '--version'], required=False))) + if args.platform == 'alpine': + binary = (source / 'rsync').read_bytes() + version = json.loads(subprocess.check_output(['./rsync', '-VV'], text=True)) + if binary[:4] != b'\x7fELF' or binary[4] != int(env['BITS']) // 32 or version['capabilities']['file_bits'] != 64: + raise ValueError('Unexpected Alpine binary or file-offset width') + if features: + status |= bool(command('Features', [sys.executable, '-S', 'testsuite/tools/check-features.py', + str(source / 'rsync'), *features], required=False, + destination=output / 'features.json')) + if args.platform == 'alpine': + for argv in (['addgroup', '-S', 'rsynctest'], ['addgroup', '-S', 'rsynctest-extra'], + ['adduser', '-S', '-D', '-h', '/home/rsynctest', '-G', 'rsynctest', 'rsynctest'], + ['addgroup', 'rsynctest', 'rsynctest-extra']): + command('Test account', argv) + account = None if args.platform == 'solaris' else pwd.getpwnam('rsynctest') + if account: + (output / 'nonroot').mkdir() + os.chown(output / 'nonroot', account.pw_uid, account.pw_gid) + if args.platform != 'alpine': + status |= bool(command('Build directory probe', ['make', 'dirfd-probe'], required=False)) + if account: + status |= bool(command('Directory descriptors', [str(source / 'dirfd-probe'), str(output / 'nonroot')], + required=False, account=account, + destination=output / 'nonroot' / 'dirfd-probe.tsv')) + passes = [('root', 'pipe', None), ('root', 'tcp', None)] + if args.platform == 'alpine': + passes[1:1] = [('root', 'protocol-30', 30), ('root', 'protocol-29', 29)] + if account: + passes += [('nonroot', 'pipe', None), ('nonroot', 'tcp', None)] + for role, transport, protocol in passes: + selected = f'{role},{profiles}' + ('' if transport == 'tcp' else ',pipe') + options = [] + if transport == 'tcp': + options += ['--use-tcp', '--daemon-tests-only'] + if protocol: + selected += f',protocol-{protocol}' + options += [f'--protocol={protocol}'] + parallel = 4 if args.platform == 'alpine' else 2 if args.platform == 'openbsd' and transport == 'tcp' else 8 + status |= bool(command(f'Tests ({"non-root" if role == "nonroot" else role} {transport})', + [sys.executable, '-S', 'testsuite/tools/check.py', str(source), str(source / 'rsync'), + selected, str(output / role / transport), *options, f'-j{parallel}'], + required=False, account=account if role == 'nonroot' else None)) + except (KeyError, OSError, ValueError, subprocess.SubprocessError, SystemExit) as error: + print(f'::error::Guest checks failed: {error}', file=sys.stderr) + status = 1 + finally: + if cross_device is not None: + try: + if mounted: + subprocess.run(['umount', str(cross_device)], check=True) + cross_device.rmdir() + except (OSError, subprocess.SubprocessError) as error: + print(f'::error::Cross-device cleanup failed: {error}', file=sys.stderr) + status = 1 + if args.platform == 'alpine' and source is not None: + (output / 'bin').mkdir(exist_ok=True) + for name in ('rsync', 'rsync-ssl', 'rsync.1', 'rsync-ssl.1', 'rsyncd.conf.5', 'rrsync.1', 'rrsync'): + if (source / name).is_file(): + shutil.copy2(source / name, output / 'bin' / name) + if (source / 'config.log').is_file(): + shutil.copyfile(source / 'config.log', output / 'config.log') + (output / 'status').write_text(f'{int(status)}\n') + for sig, handler in previous.items(): + signal.signal(sig, handler) + return int(status) if args.platform == 'alpine' else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/install-cygwin.ps1 b/.github/scripts/install-cygwin.ps1 new file mode 100644 index 000000000..5e6f1c851 --- /dev/null +++ b/.github/scripts/install-cygwin.ps1 @@ -0,0 +1,33 @@ +param([switch]$OpenSSH) + +$ErrorActionPreference = 'Stop' +if ($Env:GITHUB_ACTIONS -ne 'true' -or $Env:RUNNER_ENVIRONMENT -ne 'github-hosted') { + throw 'Cygwin installation is restricted to disposable hosted runners' +} +$setup = Join-Path $Env:RUNNER_TEMP 'setup-x86_64.exe' +$sums = Join-Path $Env:RUNNER_TEMP 'cygwin-sha512.sum' +$packages = 'make,autoconf,automake,gcc-core,attr,libattr-devel,python39,python39-pip,libzstd-devel,liblz4-devel,libssl-devel,libxxhash0,libxxhash-devel,libidn2-devel' +if ($OpenSSH) { $packages += ',openssh,openssl' } + +Invoke-WebRequest https://cygwin.com/setup-x86_64.exe -OutFile $setup +Invoke-WebRequest https://cygwin.com/sha512.sum -OutFile $sums +$sum = Select-String -LiteralPath $sums -Pattern '^[0-9a-fA-F]{128}\s+\*?setup-x86_64\.exe$' | Select-Object -First 1 +if (-not $sum) { throw 'setup-x86_64.exe is missing from Cygwin sha512.sum' } +$expected = ($sum.Line -split '\s+')[0] +$actual = (Get-FileHash -LiteralPath $setup -Algorithm SHA512).Hash +if ($actual -ine $expected) { throw 'Cygwin setup SHA-512 mismatch' } + +$arguments = @( + '--quiet-mode', '--no-desktop', '--no-startmenu', '--no-shortcuts', + '--root', 'C:\tools\cygwin', + '--local-package-dir', (Join-Path $Env:RUNNER_TEMP 'cygwin-packages'), + '--site', 'https://mirrors.kernel.org/sourceware/cygwin/', + '--packages', $packages +) +$install = Start-Process -FilePath $setup -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden +if ($install.ExitCode -ne 0) { exit $install.ExitCode } +$bash = 'C:\tools\cygwin\bin\bash.exe' +if (-not (Test-Path -LiteralPath $bash)) { throw 'Cygwin setup did not install bash' } +& $bash -lc 'command -v make aclocal gcc python3 >/dev/null' +if ($LASTEXITCODE -ne 0) { throw 'Cygwin setup did not install all required build tools' } +'C:/tools/cygwin/bin' >>$Env:GITHUB_PATH diff --git a/.github/scripts/installcheck.py b/.github/scripts/installcheck.py new file mode 100644 index 000000000..5685a4ba1 --- /dev/null +++ b/.github/scripts/installcheck.py @@ -0,0 +1,30 @@ +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + with tempfile.TemporaryDirectory(prefix='rsync-install-') as directory: + root = Path(directory) + subprocess.run(['make', 'install-all', f'DESTDIR={root}'], check=True) + required = ['usr/local/bin/rsync', 'usr/local/bin/rsync-ssl', 'usr/local/bin/rrsync', + 'usr/local/share/man/man1/rsync.1', 'usr/local/share/man/man1/rsync-ssl.1', + 'usr/local/share/man/man1/rrsync.1', 'usr/local/share/man/man5/rsyncd.conf.5', + 'etc/stunnel/rsyncd.conf'] + missing = [name for name in required if not (root / name).exists()] + if missing: + raise ValueError('Missing installed files: ' + ', '.join(missing)) + subprocess.run(['make', 'uninstall-all', f'DESTDIR={root}'], check=True) + leftover = [str(path.relative_to(root)) for path in root.rglob('*') if path.is_file() or path.is_symlink()] + if leftover: + raise ValueError('Uninstall left files: ' + ', '.join(leftover)) + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Installation check failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/scripts/interop-client.py b/.github/scripts/interop-client.py new file mode 100644 index 000000000..79836c507 --- /dev/null +++ b/.github/scripts/interop-client.py @@ -0,0 +1,161 @@ +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile +import time +import urllib.request + +from interop_network import NetworkError, NoRedirect + +VERSION = '1.104.4' +LINUX_SHA = '624ea093d47d62cf56f17b8332d74b8decda4a1faa610ebc60a1de334b2b3dc9' +WINDOWS_SHA = '1d12cd8b1d6dbb447da8579b1a5268ae2fc050967bebfaa02e32d2bb645ef6ee' +SOURCE = '27047635f237d1f3e0b08c0f487674757f30d2f7' + + +def run(argv, timeout=60, check=True, **options): + try: + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, **options) + except FileNotFoundError: + raise NetworkError('Required command not found: ' + Path(argv[0]).name) from None + if check and result.returncode: + raise NetworkError(Path(argv[0]).name + ' failed with exit code ' + str(result.returncode)) + return result + + +def download(url, path, wanted): + digest, size = hashlib.sha256(), 0 + with urllib.request.build_opener(NoRedirect()).open(url, timeout=30) as stream, path.open('xb') as output: + while True: + data = stream.read(65536) + if not data: + break + size += len(data) + if size > 150 * 1024 * 1024: + raise NetworkError('Tailscale package exceeds limit') + digest.update(data) + output.write(data) + if digest.hexdigest() != wanted: + raise NetworkError('Tailscale package checksum differs') + + +def install(root): + if os.environ.get('GITHUB_ACTIONS') != 'true' or os.environ.get('RUNNER_ENVIRONMENT') != 'github-hosted': + raise NetworkError('Client installation is restricted to disposable hosted runners') + binary = root / 'bin' + binary.mkdir() + if sys.platform.startswith('cygwin'): + package = root / 'tailscale.msi' + download(f'https://pkgs.tailscale.com/stable/tailscale-setup-{VERSION}-amd64.msi', package, WINDOWS_SHA) + native = run(['cygpath', '-w', str(package)]).stdout.decode().strip() + run(['msiexec.exe', '/quiet', '/norestart', '/i', native], timeout=180) + return dict(command=['/cygdrive/c/Program Files/Tailscale/tailscale.exe'], windows=True) + if sys.platform == 'darwin': + env = dict(os.environ, GOBIN=str(binary), GOENV='off', GOWORK='off', GOFLAGS='', + GOPROXY='https://proxy.golang.org', GOSUMDB='sum.golang.org', GONOSUMDB='', GOPRIVATE='') + run(['go', 'install', 'tailscale.com/cmd/tailscale@' + SOURCE, + 'tailscale.com/cmd/tailscaled@' + SOURCE], env=env, timeout=900) + else: + package = root / 'tailscale.tgz' + download(f'https://pkgs.tailscale.com/stable/tailscale_{VERSION}_amd64.tgz', package, LINUX_SHA) + with tarfile.open(str(package), 'r:gz') as archive: + for name in ('tailscale', 'tailscaled'): + members = [member for member in archive.getmembers() if member.name == f'tailscale_{VERSION}_amd64/{name}'] + if len(members) != 1 or not members[0].isreg() or members[0].size > 100 * 1024 * 1024: + raise NetworkError('Unexpected client package member') + with archive.extractfile(members[0]) as source, (binary / name).open('xb') as output: + shutil.copyfileobj(source, output) + (binary / name).chmod(0o755) + return dict(command=['sudo', str(binary / 'tailscale'), '--socket=' + str(root / 'tailscaled.sock')], windows=False) + + +def daemon(root): + env = {key: os.environ[key] for key in ('PATH', 'RUNNER_TRACKING_ID') if key in os.environ} + with (root / 'tailscaled.log').open('wb') as log: + with subprocess.Popen([str(root / 'bin' / 'tailscaled'), '--state=mem:', '--socket=' + str(root / 'tailscaled.sock')], + env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=log) as child: + try: + deadline = time.monotonic() + 3000 + while not (root / 'daemon-stop').exists() and time.monotonic() < deadline: + if child.poll() is not None: + raise NetworkError('Tailscale daemon exited') + time.sleep(0.1) + finally: + if child.poll() is None: + child.terminate() + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=5) + (root / 'daemon-stopped').touch() + + +def connect(root, key, hostname): + client = json.loads((root / 'client.json').read_text()) + if not client['windows']: + env = {name: os.environ[name] for name in ('PATH', 'RUNNER_TRACKING_ID') if name in os.environ} + subprocess.Popen(['sudo', '--preserve-env=RUNNER_TRACKING_ID', sys.executable, '-B', '-S', + str(Path(__file__).resolve()), 'daemon', '--root', str(root)], + env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + (root / 'daemon-started').touch() + deadline = time.monotonic() + 30 + while True: + result = run([*client['command'], 'status', '--json'], check=False) + if result.stdout.startswith(b'{'): + break + if time.monotonic() >= deadline: + raise NetworkError('Tailscale daemon did not start') + time.sleep(0.2) + path = str(key) + if client['windows']: + path = run(['cygpath', '-w', path]).stdout.decode().strip() + run([*client['command'], 'up', '--auth-key=file:' + path, '--hostname=' + hostname, + '--accept-routes=false', '--accept-dns=false', '--timeout=60s'], timeout=75) + prefs = json.loads(run([*client['command'], 'debug', 'prefs']).stdout) + if any(prefs.get(key) is not False for key in ('RouteAll', 'CorpDNS')): + raise NetworkError('Route or DNS acceptance is still enabled') + return json.loads(run([*client['command'], 'status', '--json']).stdout)['Self'] + + +def stop(root): + if not (root / 'client.json').exists(): + return + client = json.loads((root / 'client.json').read_text()) + if not client['windows'] and not (root / 'daemon-started').exists(): + return + try: + run([*client['command'], 'logout']) + finally: + if client['windows']: + run(['sc.exe', 'stop', 'Tailscale'], check=False) + else: + (root / 'daemon-stop').touch() + deadline = time.monotonic() + 15 + while not (root / 'daemon-stopped').exists(): + if time.monotonic() >= deadline: + raise NetworkError('Tailscale daemon did not stop') + time.sleep(0.1) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('phase', choices=('daemon', 'stop')) + parser.add_argument('--root', type=Path, required=True) + args = parser.parse_args() + try: + daemon(args.root) if args.phase == 'daemon' else stop(args.root) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print('Tailscale client failed: ' + type(error).__name__, file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/interop-endpoint.py b/.github/scripts/interop-endpoint.py new file mode 100644 index 000000000..502024d77 --- /dev/null +++ b/.github/scripts/interop-endpoint.py @@ -0,0 +1,263 @@ +import argparse +from contextlib import ExitStack +import ipaddress +import json +import os +from pathlib import Path +import pwd +import re +import signal +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'testsuite')) +from harness.interoperability import PLATFORMS, endpoint_identity, seed, snapshot, transfer_cases, update +from harness.process import quote_command +from harness.ssh import ProcessServer, SSHServer, client_command, command, public_key + +SSH_PORT, DAEMON_PORT = 18731, 18733 + + +def name(case): + if tuple(case) not in transfer_cases(): + raise ValueError('Unknown transfer case') + return '-'.join(case) + + +def publish(root, filename, data): + temporary = root / (filename + '.tmp') + temporary.write_text(json.dumps(data, sort_keys=True) + '\n') + temporary.replace(root / (filename + '.json')) + + +def read(root, filename): + with (root / (filename + '.json')).open('rb') as stream: + data = stream.read(65537) + if len(data) > 65536: + raise ValueError('Endpoint record exceeds limit') + value = json.loads(data) + if not isinstance(value, dict): + raise ValueError('Invalid endpoint record') + return value + + +def local_cases(role, field): + return [case for case in transfer_cases() if case[field] == role] + + +def serve(root, role, binary, rrsync, address='127.0.0.1', revision=None): + try: + identity = endpoint_identity(binary, role) + if revision: + repository = Path(__file__).resolve().parents[2] + actual = subprocess.check_output(['git', '-c', 'safe.directory=' + str(repository), '-C', str(repository), + 'rev-parse', 'HEAD'], text=True, timeout=10).strip() + if actual != revision: + raise ValueError('Native endpoint has a different candidate revision') + identity['source_sha'] = actual + data = root / 'data' + (data / 'source').mkdir(parents=True) + (data / 'destination').mkdir() + for case in local_cases(role, 0): + seed(data / 'source' / name(case)) + for case in local_cases(role, 1): + (data / 'destination' / name(case)).mkdir() + seed(root / 'reference') + env = {key: os.environ[key] for key in ('PATH', 'RUNNER_TRACKING_ID') if key in os.environ} + env.update(HOME=str(root), LC_ALL='C') + conf = root / 'rsyncd.conf' + conf.write_text(f'use chroot = no\nuid = {os.geteuid()}\ngid = {os.getegid()}\n' + f'log file = {root / "rsyncd.log"}\n' + f'[data]\npath = {data}\nread only = no\nmunge symlinks = no\n') + daemon = root / 'daemon' + daemon.mkdir() + with ExitStack() as resources: + # Cases have disjoint trees, so the per-directory convenience lock is unnecessary. + overlay = address != '127.0.0.1' + ssh = resources.enter_context(SSHServer(root / 'ssh', data, binary, rrsync, address=address, + port=SSH_PORT if overlay else 0, parallel=True)) + daemon = resources.enter_context(ProcessServer([str(binary), '--daemon', '--no-detach', '--config=' + str(conf)], + env, daemon, address, DAEMON_PORT if overlay else 0)) + if overlay: + resources.enter_context(ProcessServer([ssh.sshd, '-i', '-e', '-f', str(ssh.config)], + ssh.env, ssh.directory, address, 18732)) + keys = {key: public_key(' '.join((root / 'ssh' / (key + '.pub')).read_text().split()[:2])) + for key in ('client', 'host')} + publish(root, 'local', dict(identity=identity, ssh_user=ssh.user, host_key=keys['host'], + client_key=keys['client'], ssh_port=ssh.port, daemon_port=daemon.port)) + deadline = time.monotonic() + 3000 + while not (root / 'stop').exists(): + if time.monotonic() >= deadline: + raise ValueError('Endpoint service deadline expired') + time.sleep(0.1) + (root / 'stopped').touch() + except BaseException as error: + publish(root, 'error', dict(error=str(error) if isinstance(error, ValueError) else type(error).__name__)) + raise + finally: + for filename in ('client', 'host'): + path = root / 'ssh' / filename + if path.exists(): + path.unlink() + + +def prepare(role, binary, rrsync, address='127.0.0.1', revision=None): + root = Path(tempfile.mkdtemp(prefix='rsync-interop-', dir=pwd.getpwuid(os.geteuid()).pw_dir)) + env = {key: os.environ[key] for key in ('PATH', 'RUNNER_TRACKING_ID') if key in os.environ} + with (root / 'agent.log').open('wb') as log: + subprocess.Popen([sys.executable, '-B', '-S', str(Path(__file__).resolve()), 'serve', role, '--root', str(root), + '--binary', str(binary), '--rrsync', str(rrsync), '--address', address, + *(['--source-sha', revision] if revision else [])], env=env, stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + deadline = time.monotonic() + 15 + while not (root / 'local.json').exists(): + if (root / 'error.json').exists(): + raise ValueError(read(root, 'error')['error']) + if time.monotonic() >= deadline: + (root / 'stop').touch() + raise ValueError('Endpoint services did not start') + time.sleep(0.1) + return dict(read(root, 'local'), root=str(root), python=sys.executable, script=str(Path(__file__).resolve())) + + +def configure(root, role, nodes): + if not isinstance(nodes, dict) or set(nodes) != set(PLATFORMS): + raise ValueError('Incomplete endpoint set') + hosts, clients, addresses, host_keys = [], [], set(), set() + for peer, record in nodes.items(): + if not isinstance(record, dict) or not isinstance(record.get('identity'), dict): + raise ValueError('Invalid native endpoint record') + native = record['identity'] + system = native.get('system', '') + if (native.get('platform') != peer or not isinstance(system, str) + or not (system.startswith('CYGWIN_') if peer == 'cygwin' else system == PLATFORMS[peer]) + or not isinstance(native.get('binary_sha256'), str) or not re.fullmatch(r'[0-9a-f]{64}', native['binary_sha256']) + or not isinstance(record.get('ssh_user'), str) + or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_-]*\$?', record['ssh_user'])): + raise ValueError('Invalid native endpoint identity') + address = record['address'] + if not isinstance(address, str) or address in addresses or ipaddress.ip_address(address) not in ipaddress.ip_network('100.64.0.0/10'): + raise ValueError('Invalid or repeated overlay address') + addresses.add(address) + host_key = public_key(record['host_key']) + hosts.append(f'[{address}]:{SSH_PORT} ' + host_key) + host_keys.add(host_key) + clients.append(public_key(record['client_key'])) + own = read(root, 'local') + if (nodes[role]['identity'] != own['identity'] or nodes[role]['host_key'] != own['host_key'] + or nodes[role]['client_key'] != own['client_key'] or len(set(clients)) != len(PLATFORMS) + or len(host_keys) != len(PLATFORMS) or (root / 'peers.json').exists()): + raise ValueError('Topology replaced an endpoint identity or reused a key') + (root / 'ssh' / 'known_hosts').write_text('\n'.join(hosts) + '\n') + keys = root / 'ssh' / 'authorized_keys.tmp' + keys.write_text('\n'.join(clients) + '\n') + keys.chmod(0o600) + keys.replace(root / 'ssh' / 'authorized_keys') + publish(root, 'peers', nodes) + + +def transfers(root, role, binary, phase): + nodes = read(root, 'peers') + commands = [] + for case in transfer_cases(): + source, destination, client, transport = case + if (source if client == 'push' else destination) != role: + continue + case_name = name(case) + source_path, destination_path = 'source/' + case_name + '/', 'destination/' + case_name + '/' + peer = nodes[destination if client == 'push' else source] + remote_path = destination_path if client == 'push' else source_path + if transport == 'ssh': + remote = f'{peer["ssh_user"]}@{peer["address"]}:{remote_path}' + options = ['--old-args', '--rsync-path=rsync', '-e', quote_command(client_command(root / 'ssh', SSH_PORT))] + else: + remote = f'rsync://{peer["address"]}:{DAEMON_PORT}/data/{remote_path}' + options = [] + local = str(root / 'data' / (source_path if client == 'push' else destination_path)) + '/' + operands = [local, remote] if client == 'push' else [remote, local] + env = {key: os.environ[key] for key in ('PATH', 'RUNNER_TRACKING_ID') if key in os.environ} + result = command([str(binary), '-rltpH', *options, *(['--checksum', '--delete'] if phase == 2 else []), + *operands], env, timeout=60) + if result.returncode: + raise ValueError(f'{case_name}: rsync exited {result.returncode}: ' + + result.stderr.decode(errors='replace')[-2048:]) + commands.append(list(case)) + publish(root, 'transferred-' + str(phase), dict(commands=commands)) + return dict(commands=commands) + + +def verify(root, role, phase): + expected = snapshot(root / 'reference') + verified = [] + for case in local_cases(role, 1): + if snapshot(root / 'data' / 'destination' / name(case)) != expected: + raise ValueError(name(case) + ': transferred data or metadata differ') + verified.append(list(case)) + if phase == 1: + for case in local_cases(role, 0): + update(root / 'data' / 'source' / name(case)) + update(root / 'reference') + publish(root, 'verified-' + str(phase), dict(verified=verified)) + return dict(verified=verified) + + +def stop(root): + (root / 'stop').touch() + deadline = time.monotonic() + 15 + while not (root / 'stopped').exists(): + if (root / 'error.json').exists(): + raise ValueError(read(root, 'error')['error']) + if time.monotonic() >= deadline: + raise ValueError('Endpoint services did not stop') + time.sleep(0.1) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('phase', choices=('prepare', 'serve', 'configure', 'transfer1', 'next', 'transfer2', 'verify', 'stop')) + parser.add_argument('platform', choices=PLATFORMS) + parser.add_argument('--root', type=Path) + parser.add_argument('--binary', type=Path) + parser.add_argument('--rrsync', type=Path) + parser.add_argument('--address', default='127.0.0.1') + parser.add_argument('--source-sha') + args = parser.parse_args() + if (args.phase in ('prepare', 'serve') and (args.binary is None or args.rrsync is None) + or args.phase != 'prepare' and args.root is None): + parser.error('The phase is missing its endpoint paths') + if args.source_sha is not None and not re.fullmatch(r'[0-9a-f]{40}', args.source_sha): + parser.error('Invalid source revision') + def interrupted(signum, frame): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + raise ValueError('Endpoint interrupted') + signal.signal(signal.SIGTERM, interrupted) + try: + result = {} + if args.phase in ('prepare', 'serve'): + binary, rrsync = args.binary.resolve(strict=True), args.rrsync.resolve(strict=True) + if args.phase == 'prepare': + result = prepare(args.platform, binary, rrsync, args.address, args.source_sha) + else: + serve(args.root, args.platform, binary, rrsync, args.address, args.source_sha) + elif args.phase == 'configure': + data = sys.stdin.buffer.read(65537) + if len(data) > 65536: + raise ValueError('Peer manifest exceeds limit') + configure(args.root, args.platform, json.loads(data)) + elif args.phase in ('transfer1', 'transfer2'): + result = transfers(args.root, args.platform, args.binary.resolve(strict=True), int(args.phase[-1])) + elif args.phase in ('next', 'verify'): + result = verify(args.root, args.platform, 1 if args.phase == 'next' else 2) + else: + stop(args.root) + print(json.dumps(result, sort_keys=True)) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print('Endpoint failed: ' + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/interop-host.py b/.github/scripts/interop-host.py new file mode 100644 index 000000000..cea5948dc --- /dev/null +++ b/.github/scripts/interop-host.py @@ -0,0 +1,262 @@ +import argparse +import ipaddress +import json +import os +from pathlib import Path +import runpy +import shlex +import socket +import subprocess +import sys +import time + +from interop_ci import ROLES, identity, published, records, validate, write +from interop_enrol import decrypt, forget, generate +from interop_network import NetworkError, require_no_oidc + +client = runpy.run_path(str(Path(__file__).with_name('interop-client.py'))) + + +def path(value): + if sys.platform.startswith('cygwin') and len(value) > 2 and value[1] == ':': + value = subprocess.check_output(['cygpath', '-u', value], text=True, timeout=10).strip() + return Path(value) + + +def call(root, role, phase, data=None): + guest = os.environ.get('INTEROP_GUEST') == 'true' + workspace = path(os.environ['GITHUB_WORKSPACE']) + args = ['python3', '-B', '-S', str(workspace / '.github/scripts/interop-endpoint.py'), phase, role] + if phase == 'prepare': + args += ['--binary', str(workspace / ('rsync.exe' if role == 'cygwin' else 'rsync')), + '--rrsync', str(workspace / 'support/rrsync'), '--source-sha', identity()['source_sha']] + if not guest: + args += ['--address', json.loads((root / 'node.json').read_text())['address']] + else: + endpoint = json.loads((root / 'endpoint.json').read_text()) + args += ['--root', endpoint['root']] + if phase in ('transfer1', 'transfer2'): + args += ['--binary', str(workspace / ('rsync.exe' if role == 'cygwin' else 'rsync'))] + if guest: + remote = ['env', 'PATH=/usr/local/bin:/usr/local/sbin:/usr/pkg/bin:/usr/pkg/sbin:/usr/gnu/bin:/usr/bin:/bin:/usr/sbin:/sbin', *args] + args = ['ssh', '-oBatchMode=yes', '-oConnectTimeout=10', role, ' '.join(map(shlex.quote, remote))] + else: + args[0] = sys.executable + if not sys.platform.startswith('cygwin'): + args = ['sudo', '--preserve-env=RUNNER_TRACKING_ID', *args] + result = subprocess.run(args, input=None if data is None else json.dumps(data).encode(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + timeout=1600 if phase.startswith('transfer') else 45) + if result.returncode: + raise NetworkError(f'{role} {phase} failed: ' + result.stderr.decode(errors='replace')[-2048:]) + if len(result.stdout) > 65536: + raise NetworkError('Native endpoint output exceeds limit') + return json.loads(result.stdout) + + +def forward(root, role): + node = json.loads((root / 'node.json').read_text()) + endpoint = json.loads((root / 'endpoint.json').read_text()) + argv = ['ssh', '-N', '-oBatchMode=yes', '-oExitOnForwardFailure=yes', '-oConnectTimeout=10'] + for port, target in ((18731, endpoint['ssh_port']), (18732, endpoint['ssh_port']), (18733, endpoint['daemon_port'])): + argv += ['-L', f'{node["address"]}:{port}:127.0.0.1:{target}'] + argv.append(role) + with (root / 'forward.log').open('wb') as log, subprocess.Popen(argv, stdout=log, stderr=log) as process: + try: + deadline = time.monotonic() + 3000 + while not (root / 'forward-stop').exists() and time.monotonic() < deadline: + if process.poll() is not None: + raise NetworkError('Guest fixture forwarding exited') + time.sleep(0.1) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + (root / 'forward-stopped').touch() + + +def connect(address, port): + try: + with socket.create_connection((address, port), timeout=2): + return True + except (socket.timeout, ConnectionRefusedError, ConnectionResetError, PermissionError): + return False + except OSError as error: + import errno + if error.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ETIMEDOUT): + return False + raise + + +def healthy(node): + if not all(connect(node['address'], port) for port in (18731, 18732, 18733)): + raise NetworkError('Local fixture services are not listening') + + +def network(root, role, phase): + own = json.loads((root / 'ready.json').read_text()) + topology = published('topology')['nodes'] + if set(topology) != set(ROLES) or topology[role] != own: + raise NetworkError('Network topology changed the local endpoint') + healthy(own) + if phase == 'quarantine': + if any(connect(peer['address'], 18731) for name, peer in topology.items() if name != role): + raise NetworkError('A quarantined peer is reachable') + elif phase == 'admit': + admission = published('admission') + if admission['nodes'] != topology: + raise NetworkError('Admission replaced the endpoint set') + deadline = time.monotonic() + 60 + for name, peer in topology.items(): + if name == role: + continue + while not all(connect(peer['address'], port) for port in (18731, 18733)): + if time.monotonic() >= deadline: + raise NetworkError('An admitted endpoint is unreachable') + time.sleep(1) + if connect(peer['address'], 18732): + raise NetworkError('A forbidden fixture port is reachable') + call(root, role, 'configure', topology) + else: + if published('revocation').get('revoked') is not True: + raise NetworkError('Network revocation was not recorded') + deadline = time.monotonic() + 60 + for name, peer in topology.items(): + if name == role: + continue + while any(connect(peer['address'], port) for port in (18731, 18733)): + if time.monotonic() >= deadline: + raise NetworkError('A revoked endpoint remains reachable') + time.sleep(1) + healthy(own) + if phase != 'admit': + write(root, phase, role=role, node_id=own['node_id'], passed=True) + else: + write(root, 'admitted', role=role, node_id=own['node_id'], passed=True) + + +def stop(root, role): + errors = [] + if (root / 'endpoint.json').exists(): + try: + call(root, role, 'stop') + except (OSError, ValueError, subprocess.SubprocessError) as error: + errors.append(type(error).__name__) + if (root / 'forward.started').exists(): + (root / 'forward-stop').touch() + deadline = time.monotonic() + 15 + while not (root / 'forward-stopped').exists(): + if time.monotonic() >= deadline: + errors.append('Guest forwarding did not stop') + break + time.sleep(0.1) + try: + client['stop'](root) + finally: + forget(root) + if errors: + raise NetworkError('; '.join(errors)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('phase', choices=('install', 'recipient', 'connect', 'prepare', 'forward', 'quarantine', 'admit', + 'transfer1', 'next', 'transfer2', 'verify', 'revoked', 'stop', 'result')) + parser.add_argument('platform', choices=(*ROLES, 'all')) + parser.add_argument('--root', type=Path, required=True) + args = parser.parse_args() + root = path(str(args.root)).resolve() + root.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + require_no_oidc() + role, phase = args.platform, args.phase + if phase == 'stop': + stop(root, role) + return 0 + if phase == 'forward': + forward(root, role) + return 0 + if phase == 'install': + (root / 'client.json').write_text(json.dumps(client['install'](root))) + return 0 + state = identity() + if phase == 'recipient': + generate(root, role, state) + elif phase == 'connect': + enrolment = published('enrol')['recipients'][role] + decrypt(root, role, enrolment, state) + try: + node = client['connect'](root, root / 'enrol-key', f'ci-{state["run_id"]}-{state["run_attempt"]}-{role}') + finally: + forget(root) + addresses = [value for value in node['TailscaleIPs'] if ':' not in value] + if len(addresses) != 1 or ipaddress.ip_address(addresses[0]) not in ipaddress.ip_network('100.64.0.0/10'): + raise NetworkError('No unique Tailscale IPv4 address') + deadline = time.monotonic() + 15 + while True: + try: + with socket.socket() as listener: + listener.bind((addresses[0], 0)) + break + except OSError: + if time.monotonic() >= deadline: + raise NetworkError('The allocated overlay address is not available locally') from None + time.sleep(0.1) + (root / 'node.json').write_text(json.dumps(dict(node_id=node['ID'], address=addresses[0], key_id=enrolment['key_id']))) + elif phase == 'prepare': + endpoint = call(root, role, 'prepare') + if endpoint.get('identity', {}).get('platform') != role: + raise NetworkError('The host substituted for the native endpoint') + (root / 'endpoint.json').write_text(json.dumps(endpoint)) + if os.environ.get('INTEROP_GUEST') == 'true': + env = {key: os.environ[key] for key in ('PATH', 'RUNNER_TRACKING_ID') if key in os.environ} + subprocess.Popen([sys.executable, '-B', '-S', str(Path(__file__).resolve()), 'forward', role, + '--root', str(root)], env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + (root / 'forward.started').touch() + own = json.loads((root / 'node.json').read_text()) + deadline = time.monotonic() + 15 + while not connect(own['address'], 18731): + if time.monotonic() >= deadline: + raise NetworkError('Guest fixture forwarding did not start') + time.sleep(0.1) + public = {key: endpoint[key] for key in ('identity', 'ssh_user', 'host_key', 'client_key')} + write(root, 'ready', **public, **json.loads((root / 'node.json').read_text()), role=role, oidc_available=False) + elif phase in ('quarantine', 'admit', 'revoked'): + network(root, role, phase) + elif phase in ('transfer1', 'transfer2'): + records('admitted' if phase == 'transfer1' else 'next', timeout=600) + result = call(root, role, phase) + write(root, phase, **result) + elif phase in ('next', 'verify'): + records('transfer1' if phase == 'next' else 'transfer2', timeout=1800) + result = call(root, role, phase) + write(root, phase, **result) + if phase == 'verify': + own = json.loads((root / 'ready.json').read_text()) + commands = json.loads((root / 'transfer2.json').read_text())['commands'] + write(root, 'result', identity=own['identity'], node_id=own['node_id'], passed=True, + commands=commands, verified=result['verified']) + else: + jobs = json.loads(os.environ['INTEROP_JOBS']) + if set(jobs) != set(ROLES) or any(job.get('result') != 'success' for job in jobs.values()): + raise NetworkError('A native endpoint job did not succeed') + broker = runpy.run_path(str(Path(__file__).with_name('interop-broker.py'))) + broker['verify_cases'](records('result'), state['source_sha']) + proof = published('result') + if proof.get('passed') is not True or proof.get('cleaned') is not True or proof.get('roles') != list(ROLES): + raise NetworkError('Missing verified network cleanup') + print('Verified all 168 directed transfers and network cleanup') + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + detail = str(error) if isinstance(error, NetworkError) else type(error).__name__ + print('Interoperability failed: ' + detail, file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/scripts/test-android.py b/.github/scripts/test-android.py new file mode 100644 index 000000000..3d24d5c03 --- /dev/null +++ b/.github/scripts/test-android.py @@ -0,0 +1,173 @@ +from contextlib import redirect_stderr, redirect_stdout +import hashlib +from io import StringIO +from pathlib import Path +import runpy +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +android = runpy.run_path(str(Path(__file__).with_name('android-emulator.py'))) +restore_binary = runpy.run_path(str(Path(__file__).with_name('android.py')))['restore_binary'] + + +class Android(unittest.TestCase): + def test_restore(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / 'rsync-3.5.2-android-x86_64' + binary.write_bytes(b'candidate') + digest = hashlib.sha256(binary.read_bytes()).hexdigest() + checksum = root / f'{binary.name}.sha256' + destination = root / 'rsync' + for value in ('not a checksum', f'{"0" * 64} {binary.name}\n', f'{digest} wrong-name\n'): + checksum.write_text(value) + with self.subTest(checksum=value), self.assertRaises(ValueError): + restore_binary(root, 'x86_64', destination) + self.assertFalse(destination.exists()) + checksum.write_text(f'{digest} {binary.name}\n') + restore_binary(root, 'x86_64', destination) + self.assertEqual(destination.read_bytes(), binary.read_bytes()) + with self.assertRaises(FileExistsError): + restore_binary(root, 'x86_64', destination) + binary.with_name('rsync-3.5.1-android-x86_64').write_bytes(b'other') + with self.assertRaises(ValueError): + restore_binary(root, 'x86_64', root / 'other') + with self.assertRaises(ValueError): + restore_binary(root, 'arm64-v8a', root / 'other') + + def device(self, changes=None): + values = {'getprop ro.build.version.sdk': '36', 'getprop ro.product.cpu.abi': 'x86_64', + 'getprop ro.kernel.qemu': '1', 'getenforce': 'Enforcing', 'id -u': '2000', + 'uname -r': '6.6.30-android15', 'getprop ro.build.fingerprint': 'test-device'} + values.update(changes or {}) + return lambda *args, **kwargs: SimpleNamespace(stdout=values[args[1]] + '\n') + + def test_device(self): + with redirect_stdout(StringIO()): + self.assertEqual(android['check_device'](self.device())['uid'], '2000') + for command, value in {'getprop ro.build.version.sdk': '35', + 'getprop ro.product.cpu.abi': 'aarch64', + 'getprop ro.kernel.qemu': '', 'getenforce': 'Permissive', + 'id -u': '0'}.items(): + with self.subTest(command=command), self.assertRaises(ValueError): + android['check_device'](self.device({command: value})) + + def test_boot(self): + process = SimpleNamespace(poll=lambda: None) + with patch('time.sleep'), patch('time.monotonic', side_effect=[0, 1]): + android['wait_for_boot'](lambda *a, **kw: SimpleNamespace(stdout='1\n'), process) + with self.assertRaises(RuntimeError): + android['wait_for_boot'](self.device(), SimpleNamespace(poll=lambda: 1)) + for failure in (SimpleNamespace(stdout=''), subprocess.CalledProcessError(1, 'adb'), + subprocess.TimeoutExpired('adb', 10)): + with patch('time.sleep'), patch('time.monotonic', side_effect=[0, 1, 301]), \ + self.assertRaises(TimeoutError): + def adb(*args, **kwargs): + if isinstance(failure, Exception): + raise failure + return failure + android['wait_for_boot'](adb, process) + + def test_permissions(self): + with tempfile.TemporaryDirectory() as directory: + for code, stderr in ((0, ''), (23, 'other error'), (12, 'Permission denied'), + (23, 'Permission denied')): + calls = [] + def adb(*args, **kwargs): + calls.append(args) + return SimpleNamespace(returncode=code, stdout='', stderr=stderr) + with self.subTest(code=code, stderr=stderr): + args = (adb, Path('rsync'), '/data/local/tmp/rsync-ci.12345678', Path(directory)) + if code == 23 and 'Permission denied' in stderr: + android['check_permissions'](*args) + self.assertIn(('shell', 'test ! -r /data/system'), calls) + self.assertTrue(any('test' in call[-1] and '/refused/private' in call[-1] + for call in calls)) + else: + with self.assertRaises(ValueError): + android['check_permissions'](*args) + + def test_stop(self): + adb = Mock() + process = Mock() + process.poll.return_value = 0 + android['stop_emulator'](adb, process) + adb.assert_not_called() + process.poll.return_value = None + android['stop_emulator'](adb, process) + adb.assert_called_once_with('emu', 'kill', timeout=10, capture_output=True) + process.terminate.assert_not_called() + process.reset_mock() + adb.side_effect = subprocess.CalledProcessError(1, 'adb') + process.wait.side_effect = [subprocess.TimeoutExpired('emulator', 10), 0] + android['stop_emulator'](adb, process) + process.terminate.assert_called_once() + process.kill.assert_called_once() + self.assertEqual(process.wait.call_count, 2) + + def test_transfer(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / 'candidate rsync' + binary.write_bytes(b'candidate') + digest = hashlib.sha256(binary.read_bytes()).hexdigest() + for mismatch, failed, cleanup_error in ((False, False, False), (True, False, False), + (False, True, False), (True, False, True)): + try: + (root / 'receipt.json').unlink() + except FileNotFoundError: + pass + calls = [] + def adb(*args, **kwargs): + calls.append(args) + command = args[-1] + if cleanup_error and command.startswith('rm -rf'): + raise subprocess.CalledProcessError(1, 'adb cleanup') + if args[0] == 'pull': + Path(args[2]).write_bytes(b'raw\r\n\0data') + if command.startswith('mktemp'): + return SimpleNamespace(stdout='/data/local/tmp/rsync-ci.12345678\n') + if command.startswith('sha256sum'): + return SimpleNamespace(stdout=f'{"0" * 64 if mismatch else digest} rsync\n') + return SimpleNamespace(stdout='') + def fixture(command, *args, **kwargs): + if failed: + raise subprocess.CalledProcessError(1, 'rsync') + command('cat', 'data', capture_output=True, text=False) + kwargs['write_file']('sample', b'raw\r\n\0data') + self.assertEqual(Path(calls[-1][1]).read_bytes(), b'raw\r\n\0data') + self.assertEqual(kwargs['read_file']('sample'), b'raw\r\n\0data') + with patch.dict(android['check_transfer'].__globals__, + check_device=lambda adb: {}, check_permissions=lambda *a: None, + transfer=fixture), \ + patch('subprocess.run', return_value=SimpleNamespace(stdout='revision\n')), \ + redirect_stderr(StringIO()): + if mismatch or failed: + with self.assertRaises(ValueError if mismatch else subprocess.CalledProcessError): + android['check_transfer'](adb, binary, root) + self.assertFalse((root / 'receipt.json').exists()) + else: + android['check_transfer'](adb, binary, root) + self.assertTrue((root / 'receipt.json').is_file()) + self.assertIn(('shell', '-T', 'cat data'), calls) + self.assertEqual(calls[-1], ('shell', 'rm -rf /data/local/tmp/rsync-ci.12345678')) + + def test_scratch(self): + for work in ('', '/', '/data/local/tmp/rsync-ci.x/../../escape.12345678', + '/data/local/tmp/rsync-ci.12345678;echo bad'): + calls = [] + def adb(*args, **kwargs): + calls.append(args) + return SimpleNamespace(stdout=work) + with patch.dict(android['check_transfer'].__globals__, check_device=lambda adb: {}), \ + self.subTest(work=work), self.assertRaises(ValueError): + android['check_transfer'](adb, Path('rsync'), Path('.')) + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test-archive.py b/.github/scripts/test-archive.py new file mode 100644 index 000000000..bf23d19b0 --- /dev/null +++ b/.github/scripts/test-archive.py @@ -0,0 +1,53 @@ +import io +from pathlib import Path +import tempfile +import tarfile +import unittest + +from archive import GEN_FILES, qualify + + +class Archive(unittest.TestCase): + def reject(self, change): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / 'source.tar.gz' + entries = {f'rsync-3.5.2/{name}': (0o644, 0, b'generated\n') for name in GEN_FILES} + for name in ('configure', 'configure.sh', 'rsync-ssl', 'support/rrsync'): + entries[f'rsync-3.5.2/{name}'] = (0o755, 0, b'executable\n') + change(entries) + with tarfile.open(archive, 'w:gz') as output: + for name, (mode, uid, content) in entries.items(): + entry = tarfile.TarInfo(name) + entry.mode, entry.uid = mode, uid + if isinstance(content, tuple): + entry.type, entry.linkname = tarfile.SYMTYPE, content[0] + output.addfile(entry) + else: + entry.size = len(content) + output.addfile(entry, io.BytesIO(content)) + with self.assertRaises(ValueError): + qualify(archive, root) + self.assertFalse((root / 'qualification.json').exists()) + + def test_missing_generated(self): + self.reject(lambda entries: entries.pop('rsync-3.5.2/rsync.1')) + + def test_executable_mode(self): + self.reject(lambda entries: entries.update({'rsync-3.5.2/configure': (0o644, 0, b'configure')})) + + def test_ownership(self): + self.reject(lambda entries: entries.update({'rsync-3.5.2/file': (0o644, 1000, b'file')})) + + def test_paths(self): + for path in ('../outside', 'rsync-3.5.2/../outside', 'rsync-3.5.2/.git/config', + 'rsync-3.5.2/.github/workflows/ci.yml'): + with self.subTest(path=path): + self.reject(lambda entries: entries.update({path: (0o644, 0, b'file')})) + + def test_link_escape(self): + self.reject(lambda entries: entries.update({'rsync-3.5.2/link': (0o777, 0, ('../../outside',))})) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test-diagnostics.py b/.github/scripts/test-diagnostics.py new file mode 100644 index 000000000..d4ded9525 --- /dev/null +++ b/.github/scripts/test-diagnostics.py @@ -0,0 +1,78 @@ +from contextlib import redirect_stdout +from io import StringIO +import os +from pathlib import Path +import runpy +import sys +import tempfile +import unittest +from unittest.mock import patch + + +diagnostics = runpy.run_path(str(Path(__file__).with_name('diagnostics.py'))) + + +class Diagnostics(unittest.TestCase): + def test_logged_failure(self): + with tempfile.TemporaryDirectory(prefix='rsync-diagnostics-') as directory: + log = Path(directory) / 'run.log' + output = StringIO() + with redirect_stdout(output): + status = diagnostics['logged']([sys.executable, '-S', '-c', + 'print("retained output"); raise SystemExit(17)'], log) + self.assertEqual(status, 17) + self.assertEqual(log.read_text(), 'retained output\n') + self.assertEqual(output.getvalue(), log.read_text()) + + def test_valgrind_results(self): + with tempfile.TemporaryDirectory(prefix='rsync-diagnostics-') as directory: + root = Path(directory) + output = root / 'scratch' / 'testtmp' / 'valgrind-logs' + output.mkdir(parents=True) + previous = Path.cwd() + os.chdir(root) + try: + argv = ['diagnostics.py', 'valgrind-results', '--privilege', 'non-root', '--transport', 'pipe'] + with patch.dict(os.environ, {'VALGRIND_SCRATCH': str(root)}), \ + patch.object(sys, 'argv', argv), redirect_stdout(StringIO()): + with self.assertRaisesRegex(ValueError, 'No Valgrind logs'): + diagnostics['main']() + path = output / 'valgrind.1.log' + path.write_text('ERROR SUMMARY: 0 errors\n') + self.assertEqual(diagnostics['main'](), 0) + self.assertEqual((root / 'test-results/valgrind-logs' / path.name).read_text(), path.read_text()) + path.write_text('ERROR SUMMARY: 2 errors\n') + with self.assertRaisesRegex(ValueError, 'Valgrind reported errors'): + diagnostics['main']() + finally: + os.chdir(previous) + + def test_summary(self): + with tempfile.TemporaryDirectory(prefix='rsync-diagnostics-') as directory: + root = Path(directory) + log = root / 'build.log' + log.write_text('compiler output\nlines: 50%\n') + summary = root / 'summary.md' + with patch.dict(os.environ, {'GITHUB_STEP_SUMMARY': str(summary)}), redirect_stdout(StringIO()): + diagnostics['summary']('Coverage', log, r'^lines:') + self.assertIn('lines: 50%', summary.read_text()) + self.assertNotIn('compiler output', summary.read_text()) + + def test_scan_flags_and_exit_status(self): + for tool in ('scan-build-18', 'scan-build-21'): + for status in (0, 17): + with self.subTest(tool=tool, status=status): + calls = [] + def logged(command, path): + calls.append(command) + return status + argv = ['diagnostics.py', 'scan', tool] + with patch.object(sys, 'argv', argv), patch.dict(diagnostics['main'].__globals__, + {'logged': logged, 'summary': lambda *args: None}): + self.assertEqual(diagnostics['main'](), status) + self.assertEqual(calls[0][0], tool) + self.assertIn('--status-bugs', calls[0]) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test-guest.py b/.github/scripts/test-guest.py new file mode 100644 index 000000000..e72c3432c --- /dev/null +++ b/.github/scripts/test-guest.py @@ -0,0 +1,93 @@ +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + + +guest = runpy.run_path(str(Path(__file__).with_name('guest.py'))) + + +class Guest(unittest.TestCase): + def run_guest(self, platform, failure=None): + with tempfile.TemporaryDirectory(prefix='rsync-guest-') as directory: + output = Path(directory) / 'results' + calls = [] + account = SimpleNamespace(pw_name='rsynctest', pw_uid=1000, pw_gid=1000, pw_dir='/home/rsynctest') + def process(command, **kwargs): + code = 1 if failure and failure(command) else 0 + calls.append((command, kwargs)) + return SimpleNamespace(wait=lambda: code, poll=lambda: code) + argv = ['guest.py', platform, '--release', '11.0', '--output', str(output)] + system = {'freebsd': 'FreeBSD', 'netbsd': 'NetBSD', 'openbsd': 'OpenBSD', 'solaris': 'SunOS'}[platform] + with patch.object(sys, 'argv', argv), patch('platform.system', return_value=system), \ + patch('platform.release', return_value='11.0'), patch('platform.machine', return_value='amd64'), \ + patch('pwd.getpwnam', return_value=account), patch('os.getgrouplist', return_value=[1000, 1001]), \ + patch('os.chown'), patch('subprocess.Popen', side_effect=process), \ + patch('subprocess.run', return_value=SimpleNamespace(returncode=0)), \ + redirect_stdout(StringIO()), redirect_stderr(StringIO()): + result = guest['main']() + return result, (output / 'status').read_text().strip(), calls + + def test_native_passes(self): + for platform in ('freebsd', 'netbsd', 'openbsd', 'solaris'): + with self.subTest(platform=platform): + result, status, calls = self.run_guest(platform) + self.assertEqual((result, status), (0, '0')) + tests = [(command, options) for command, options in calls if 'testsuite/tools/check.py' in command] + self.assertEqual(len(tests), 2 if platform == 'solaris' else 4) + for command, options in tests: + if 'nonroot' in command[5].split(','): + self.assertEqual(options['user'], 1000) + self.assertEqual(options['group'], 1000) + self.assertEqual(options['extra_groups'], [1000, 1001]) + self.assertEqual(options['env']['HOME'], '/home/rsynctest') + else: + self.assertNotIn('user', options) + if platform == 'openbsd': + self.assertTrue(all('-j2' in command for command, _ in tests if '--use-tcp' in command)) + if platform in ('netbsd', 'solaris'): + configure = next(command for command, _ in calls if command[0] == './configure') + self.assertIn('--disable-zstd', configure) + self.assertIn('--disable-idn', configure) + + def test_failed_pass_does_not_hide_remaining_results(self): + result, status, calls = self.run_guest('netbsd', lambda command: 'testsuite/tools/check.py' in command) + self.assertEqual((result, status), (0, '1')) + self.assertEqual(sum('testsuite/tools/check.py' in command for command, _ in calls), 4) + + def test_failed_build_stops_tests(self): + result, status, calls = self.run_guest('netbsd', lambda command: command[:2] == ['make', 'check-progs']) + self.assertEqual((result, status), (0, '1')) + self.assertFalse(any('testsuite/tools/check.py' in command for command, _ in calls)) + + def test_missing_probe_does_not_stop_tests(self): + def missing(command): + if command[0].endswith('/dirfd-probe'): + raise FileNotFoundError(command[0]) + return False + result, status, calls = self.run_guest('netbsd', missing) + self.assertEqual((result, status), (0, '1')) + self.assertEqual(sum('testsuite/tools/check.py' in command for command, _ in calls), 4) + + def test_host_status(self): + with tempfile.TemporaryDirectory(prefix='rsync-guest-') as directory: + path = Path(directory) / 'status' + for status in ('0', '0\n', '1', '', '00', 'false', '0\n1', ' 0', '0 ', '0\r\n'): + path.write_text(status) + with patch.object(sys, 'argv', ['guest.py', 'check', '--output', directory]), redirect_stderr(StringIO()): + self.assertEqual(guest['main'](), 0 if status in ('0', '0\n') else 1) + path.unlink() + with patch.object(sys, 'argv', ['guest.py', 'check', '--output', directory]), redirect_stderr(StringIO()): + self.assertEqual(guest['main'](), 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test-results.py b/.github/scripts/test-results.py new file mode 100644 index 000000000..ee9db1641 --- /dev/null +++ b/.github/scripts/test-results.py @@ -0,0 +1,209 @@ +import copy +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import unittest + + +checker = Path(__file__).with_name('check-results.py') +check = runpy.run_path(str(checker))['check'] + + +class Results(unittest.TestCase): + def setUp(self): + self.expected = dict(linux='true', diagnostics='true', bsd='false', macos='true', + other='true', versions='true', android='true', archive='true', valgrind='true', interoperability='true') + self.data = { + 'interoperability': {'result': 'success', 'outputs': {'jobs': json.dumps({name: {'result': 'success'} + for name in ('linux', 'freebsd', 'netbsd', 'openbsd', 'macos', 'solaris', 'cygwin', 'result')})}}, + 'archive': {'result': 'success', 'outputs': {'jobs': json.dumps({'archive': {'result': 'success'}})}}, + 'linux': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'ubuntu': {'result': 'success'}, 'almalinux': {'result': 'skipped'}}), + 'inputs': json.dumps({'ubuntu': True, 'almalinux': False})}}, + 'diagnostics': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'clang_18': {'result': 'success'}, 'clang_21': {'result': 'success'}, + 'asan': {'result': 'skipped'}, 'python_37': {'result': 'success'}}), + 'inputs': json.dumps({'clang_18': True, 'clang_21': True, 'asan': False, 'python_37': True})}}, + 'bsd': {'result': 'skipped', 'outputs': {}}, + 'macos': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'macos': {'result': 'success'}})}}, + 'other': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'cygwin': {'result': 'success'}, 'solaris': {'result': 'skipped'}}), + 'inputs': json.dumps({'cygwin': True, 'solaris': False})}}, + 'versions': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'mix': {'result': 'success'}})}}, + 'android': {'result': 'success', 'outputs': { + 'jobs': json.dumps({name: {'result': 'success'} for name in ('bionic', 'x86_64', 'termux', 'emulator')}), + 'inputs': json.dumps(dict(bionic=True, x86_64=True, termux=True, emulator=True))}}, + 'valgrind': {'result': 'success', 'outputs': { + 'jobs': json.dumps({'test': {'result': 'success'}})}}, + } + + def reject(self): + with self.assertRaises((TypeError, ValueError)): + check(self.data, self.expected) + + def test_success(self): + self.assertTrue(check(self.data, self.expected)) + + def test_families(self): + original = copy.deepcopy(self.data) + for name in original: + with self.subTest(missing=name): + self.data = {key: value for key, value in original.items() if key != name} + self.reject() + self.data = {**original, 'extra': {'result': 'success', 'outputs': {}}} + self.reject() + self.data = {} + self.reject() + + def test_family_results(self): + for name in self.data: + expected = self.data[name]['result'] + for result in ('success', 'failure', 'cancelled', 'skipped', None): + if result == expected: + continue + with self.subTest(family=name, result=result): + self.data[name]['result'] = result + self.reject() + self.data[name]['result'] = expected + + def test_selections(self): + for name in self.expected: + with self.subTest(disabled=name): + original = self.data[name] + self.expected[name] = 'false' + self.data[name] = {'result': 'skipped', 'outputs': {}} + self.assertTrue(check(self.data, self.expected)) + self.data[name] = {'result': 'success', 'outputs': {}} + self.reject() + self.data[name] = original + self.expected[name] = 'false' if name == 'bsd' else 'true' + self.expected = {name: 'false' for name in self.expected} + self.data = {name: {'result': 'skipped', 'outputs': {}} for name in self.data} + self.reject() + for value in ('TRUE', '', True, 1): + self.expected['linux'] = value + self.reject() + + def test_nested_results(self): + outputs = self.data['linux']['outputs'] + original = copy.deepcopy(outputs) + for value in ('{}', '[]', 'null', 'not JSON', None): + with self.subTest(jobs=value): + outputs['jobs'] = value + self.reject() + outputs.clear() + self.reject() + outputs.update(original) + for field in ('inputs', 'jobs'): + for value in ('[]', 'not JSON', 'null'): + with self.subTest(field=field, value=value): + outputs[field] = value + self.reject() + outputs.update(original) + jobs = json.loads(original['jobs']) + for value in ('failure', 'cancelled', 'skipped', None): + outputs['jobs'] = json.dumps({**jobs, 'ubuntu': {'result': value}}) + self.reject() + outputs['jobs'] = json.dumps({'almalinux': jobs['almalinux']}) + self.reject() + outputs['jobs'] = json.dumps({**jobs, 'almalinux': {'result': 'success'}}) + self.reject() + outputs.update(original) + for inputs in ({'unknown': True}, {'ubuntu': 'true'}, {'ubuntu': 1}, {'ubuntu': False}, + {'ubuntu': False, 'almalinux': False}): + outputs['inputs'] = json.dumps(inputs) + self.reject() + + def test_diagnostics_jobs(self): + outputs = self.data['diagnostics']['outputs'] + jobs = json.loads(outputs['jobs']) + for name in ('clang_18', 'clang_21'): + for result in ('failure', 'cancelled', 'skipped', None): + with self.subTest(job=name, result=result): + outputs['jobs'] = json.dumps({**jobs, name: {'result': result}}) + self.reject() + outputs['jobs'] = json.dumps({key: value for key, value in jobs.items() if key != name}) + self.reject() + outputs['jobs'] = json.dumps({**jobs, 'clang_18': {'result': 'skipped'}}) + outputs['inputs'] = json.dumps({'clang_18': False, 'clang_21': True, 'asan': False}) + self.assertTrue(check(self.data, self.expected)) + + def test_failed_job_cannot_be_advisory(self): + outputs = self.data['diagnostics']['outputs'] + jobs = json.loads(outputs['jobs']) + jobs['clang_21']['result'] = 'failure' + outputs['jobs'] = json.dumps(jobs) + outputs['advisory'] = 'clang_21' + self.reject() + + def test_every_family_requires_nested_results(self): + for name in ('linux', 'diagnostics', 'macos', 'other', 'versions', 'android', 'archive', 'valgrind', 'interoperability'): + original = self.data[name]['outputs'] + with self.subTest(family=name): + self.data[name]['outputs'] = {} + self.reject() + self.data[name]['outputs'] = original + outputs = self.data['macos']['outputs'] + for result in ('failure', 'cancelled', 'skipped', None): + outputs['jobs'] = json.dumps({'macos': {'result': result}}) + self.reject() + + def test_other_selections(self): + names = ('cygwin', 'solaris') + for bits in range(1, 4): + inputs = {name: bool(bits & (1 << i)) for i, name in enumerate(names)} + jobs = {name: {'result': 'success' if inputs[name] else 'skipped'} for name in names} + self.data['other']['outputs'] = {'jobs': json.dumps(jobs), 'inputs': json.dumps(inputs)} + self.assertTrue(check(self.data, self.expected)) + for name in names: + original = jobs[name]['result'] + invalid = ('failure', 'cancelled', 'skipped', None) if inputs[name] else ('success', 'failure') + for result in invalid: + with self.subTest(selections=bits, job=name, result=result): + jobs[name]['result'] = result + self.data['other']['outputs']['jobs'] = json.dumps(jobs) + self.reject() + jobs[name]['result'] = original + self.data['other']['outputs']['jobs'] = json.dumps(jobs) + self.data['other']['outputs'] = { + 'jobs': json.dumps({name: {'result': 'skipped'} for name in names}), + 'inputs': json.dumps({name: False for name in names})} + self.reject() + + def test_android_jobs(self): + outputs = self.data['android']['outputs'] + jobs = json.loads(outputs['jobs']) + for name in jobs: + for result in ('failure', 'cancelled', 'skipped', None): + with self.subTest(job=name, result=result): + outputs['jobs'] = json.dumps({**jobs, name: {'result': result}}) + self.reject() + outputs['jobs'] = json.dumps({key: value for key, value in jobs.items() if key != name}) + self.reject() + + def test_command(self): + env = {name: value for name, value in os.environ.items() if not name.startswith('CI_EXPECT_')} + env.update({f'CI_EXPECT_{name.upper()}': value for name, value in self.expected.items()}) + env['CI_RESULTS'] = json.dumps(self.data) + command = [sys.executable, '-S', str(checker)] + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + outputs = self.data['diagnostics']['outputs'] + jobs = json.loads(outputs['jobs']) + outputs['jobs'] = json.dumps({**jobs, 'clang_21': {'result': 'failure'}}) + result = subprocess.run(command, env={**env, 'CI_RESULTS': json.dumps(self.data)}, + capture_output=True, text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn('diagnostics/clang_21: failure', result.stderr) + result = subprocess.run(command, env={**env, 'CI_RESULTS': 'not JSON'}, + capture_output=True, text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/test-transfers.py b/.github/scripts/test-transfers.py new file mode 100644 index 000000000..e2bb3dac6 --- /dev/null +++ b/.github/scripts/test-transfers.py @@ -0,0 +1,233 @@ +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import interop_ci as ci +import interop_enrol as enrol +from interop_network import NetworkError + +broker = runpy.run_path(str(Path(__file__).with_name('interop-broker.py'))) +endpoint = runpy.run_path(str(Path(__file__).with_name('interop-endpoint.py'))) +client = runpy.run_path(str(Path(__file__).with_name('interop-client.py'))) +host = runpy.run_path(str(Path(__file__).with_name('interop-host.py'))) +from harness.interoperability import PLATFORMS, seed, transfer_cases, update +from harness.ssh import public_key + + +class Transfers(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.state = dict(run_id='123', run_attempt='2', source_sha='a' * 40) + self.nodes = {role: dict(self.state, role=role, node_id='n' + str(index), address='100.64.0.' + str(index)) + for index, role in enumerate(ci.ROLES, 1)} + self.run = dict(id=123, run_attempt=2, path='.github/workflows/ci.yml', name='CI', + repository=dict(id=266877886), event='pull_request', head_sha='b' * 40, + head_repository=dict(id=999, full_name='contributor/rsync'), + referenced_workflows=[dict(path=ci.WORKFLOW + 'a' * 40, sha='a' * 40)]) + self.env = patch.dict(os.environ, dict(GITHUB_REPOSITORY_ID='266877886', GITHUB_EVENT_NAME='pull_request', + GITHUB_RUN_ID='123', GITHUB_RUN_ATTEMPT='2', GH_TOKEN='fixture'), clear=True) + self.env.start() + self.addCleanup(self.env.stop) + ci.source.cache_clear() + ci.api_cache.clear() + ci.bound = None + self.addCleanup(ci.source.cache_clear) + self.addCleanup(setattr, ci, 'bound', None) + + def receipts(self): + cases = transfer_cases() + return {role: dict(platform=role, identity=dict(platform=role, source_sha=self.state['source_sha']), passed=True, + commands=[list(case) for case in cases if (case[0] if case[2] == 'push' else case[1]) == role], + verified=[list(case) for case in cases if case[1] == role]) for role in ci.ROLES} + + def test_complete_native_case_set(self): + self.assertEqual(set(ci.ROLES), set(PLATFORMS)) + cases = transfer_cases() + self.assertEqual(len(cases), 168) + self.assertEqual(len({(a, b) for a, b, _, _ in cases if 'linux' not in (a, b)}), 30) + broker['verify_cases'](self.receipts(), self.state['source_sha']) + + def test_missing_duplicate_foreign_and_misassigned_cases_fail(self): + for role in ci.ROLES: + for field in ('commands', 'verified'): + for mutation in ('missing', 'duplicate', 'foreign', 'wrong role'): + values = self.receipts() + cases = values[role][field] + if mutation == 'missing': + cases.pop() + elif mutation == 'duplicate': + cases[0] = cases[1] + elif mutation == 'foreign': + cases[0] = ['unknown', *cases[0][1:]] + else: + other = next(value for value in ci.ROLES if value != role) + cases[0] = values[other][field][0] + with self.subTest(role=role, field=field, mutation=mutation), self.assertRaises(NetworkError): + broker['verify_cases'](values, self.state['source_sha']) + values = self.receipts() + del values[role] + with self.subTest(missing=role), self.assertRaises(NetworkError): + broker['verify_cases'](values, self.state['source_sha']) + values = self.receipts() + values[role]['identity']['platform'] = 'host' + with self.subTest(substitute=role), self.assertRaises(NetworkError): + broker['verify_cases'](values, self.state['source_sha']) + + def test_merge_revision_not_head_is_tested(self): + with patch.object(ci, 'api', return_value=self.run): + self.assertEqual(ci.identity(), self.state) + self.assertNotEqual(ci.identity()['source_sha'], self.run['head_sha']) + + def test_fork_needs_no_oidc(self): + with patch.object(ci, 'api', return_value=self.run): + self.assertEqual(ci.identity(), self.state) + self.assertNotIn('ACTIONS_ID_TOKEN_REQUEST_TOKEN', os.environ) + + def test_unknown_ci_or_missing_workflow_is_refused(self): + for field, value in dict(path='.github/workflows/other.yml', run_attempt=3, status='completed', + repository=dict(id=1), referenced_workflows=[]).items(): + ci.source.cache_clear() + with self.subTest(field=field), patch.object(ci, 'api', return_value=dict(self.run, **{field: value})), self.assertRaises(NetworkError): + ci.identity() + with patch.dict(os.environ, GITHUB_EVENT_NAME='pull_request_target'), self.assertRaises(NetworkError): + ci.identity() + + def test_recovery_keeps_the_original_ci_attempt(self): + ci.bind(self.state) + with patch.object(ci, 'api', side_effect=AssertionError('Recovery read a newer CI attempt')): + self.assertEqual(ci.identity(), self.state) + for key, value in dict(run_id='../1', run_attempt='0', source_sha='not a hash').items(): + with self.subTest(key=key), self.assertRaises(NetworkError): + ci.bind(dict(self.state, **{key: value})) + + def test_stale_record_is_refused(self): + for key, value in dict(run_id='124', run_attempt='3', source_sha='b' * 40).items(): + with self.subTest(key=key), self.assertRaises(NetworkError): + ci.validate(dict(self.state, **{key: value}), self.state) + + def test_enrolment_is_private_and_bound_to_the_tested_revision(self): + enrol.generate(self.root, 'linux', self.state) + recipient = json.loads((self.root / 'public-key.json').read_text()) + record = enrol.seal(self.root, 'linux', recipient, 'key1', 'tskey-auth-fixture', self.state) + self.assertNotIn('tskey-auth-fixture', json.dumps(record)) + enrol.decrypt(self.root, 'linux', record, self.state) + self.assertEqual((self.root / 'enrol-key').read_text(), 'tskey-auth-fixture') + enrol.forget(self.root) + self.assertFalse((self.root / 'enrol-key').exists()) + with self.assertRaises(NetworkError): + enrol.seal(self.root, 'linux', recipient, 'key1', 'tskey-auth-fixture', dict(self.state, run_attempt='3')) + + def test_bad_public_keys_are_refused(self): + for value in ('', 'ssh-rsa AAAA', 'ssh-ed25519 AAAA', 'ssh-ed25519 AAAA\ncommand=evil'): + with self.subTest(value=value), self.assertRaises(ValueError): + public_key(value) + + def test_case_names_cannot_escape_the_root(self): + for case in (['../outside', 'linux', 'push', 'ssh'], ['linux', 'linux', 'push', 'ssh'], ['linux']): + with self.subTest(case=case), self.assertRaises(ValueError): + endpoint['name'](case) + + def test_native_verification_checks_initial_and_updated_data(self): + root = self.root + (root / 'data' / 'source').mkdir(parents=True) + (root / 'data' / 'destination').mkdir() + seed(root / 'reference') + for case in transfer_cases(): + if case[0] == 'linux': + seed(root / 'data' / 'source' / endpoint['name'](case)) + if case[1] == 'linux': + seed(root / 'data' / 'destination' / endpoint['name'](case)) + result = endpoint['verify'](root, 'linux', 1) + self.assertEqual(len(result['verified']), 24) + with self.assertRaises(ValueError): + endpoint['verify'](root, 'linux', 2) + for case in transfer_cases(): + if case[1] == 'linux': + update(root / 'data' / 'destination' / endpoint['name'](case)) + self.assertEqual(len(endpoint['verify'](root, 'linux', 2)['verified']), 24) + first = next(case for case in transfer_cases() if case[1] == 'linux') + (root / 'data' / 'destination' / endpoint['name'](first) / 'space name').write_text('corrupt') + with self.assertRaises(ValueError): + endpoint['verify'](root, 'linux', 2) + + def test_client_packages_are_verified_before_execution(self): + opener = type('Opener', (), {'open': lambda *_args, **_kwargs: io.BytesIO(b'not the expected package')})() + with patch('urllib.request.build_opener', return_value=opener), self.assertRaises(NetworkError): + client['download']('https://pkgs.tailscale.com/stable/example', self.root / 'package', '0' * 64) + + def test_missing_client_tool_is_identified_without_arguments(self): + with patch.object(subprocess, 'run', side_effect=FileNotFoundError(2, 'not found', 'go')): + with self.assertRaisesRegex(NetworkError, '^Required command not found: go$'): + client['run'](['go', 'fixture-not-an-access-token']) + + + def test_barriers_precede_copy_and_do_not_wait_for_their_own_upload(self): + calls = [] + def native(root, role, phase): + calls.append(phase) + return {} + def barrier(kind, **kwargs): + calls.append(kind) + return {} + shared = dict(identity=lambda: self.state, call=native, records=barrier, write=lambda *args, **kwargs: None) + for phase, expected in (('transfer1', ['admitted', 'transfer1']), ('transfer2', ['next', 'transfer2']), + ('next', ['transfer1', 'next'])): + calls.clear() + with self.subTest(phase=phase), patch.object(sys, 'argv', ['interop-host.py', phase, 'linux', '--root', str(self.root)]), \ + patch.dict(host['main'].__globals__, shared): + self.assertEqual(host['main'](), 0) + self.assertEqual(calls, expected) + + def test_denial_has_healthy_controls_before_and_after(self): + ci.bind(self.state) + (self.root / 'ready.json').write_text(json.dumps(self.nodes['linux'])) + calls = [] + def connected(address, port): + calls.append((address, port)) + return address == self.nodes['linux']['address'] + shared = dict(published=lambda *args: dict(nodes=self.nodes), connect=connected) + with patch.dict(host['network'].__globals__, shared): + host['network'](self.root, 'linux', 'quarantine') + for port in (18731, 18732, 18733): + self.assertEqual(calls.count((self.nodes['linux']['address'], port)), 2) + for role, node in self.nodes.items(): + if role != 'linux': + self.assertIn((node['address'], 18731), calls) + self.assertTrue(json.loads((self.root / 'quarantine.json').read_text())['passed']) + + def test_a_dead_service_cannot_prove_network_denial(self): + (self.root / 'ready.json').write_text(json.dumps(self.nodes['linux'])) + with patch.dict(host['network'].__globals__, published=lambda *args: dict(nodes=self.nodes), connect=lambda *args: False): + with self.assertRaisesRegex(NetworkError, 'not listening'): + host['network'](self.root, 'linux', 'quarantine') + self.assertFalse((self.root / 'quarantine.json').exists()) + + def test_client_checks_active_preferences_without_retaining_private_data(self): + (self.root / 'client.json').write_text(json.dumps(dict(command=['tailscale'], windows=False))) + prefs = dict(RouteAll=False, CorpDNS=False, Persist=dict(PrivateNodeKey='fixture-not-a-key')) + responses = [subprocess.CompletedProcess([], 0, b'{}'), subprocess.CompletedProcess([], 0, b''), + subprocess.CompletedProcess([], 0, json.dumps(prefs).encode()), + subprocess.CompletedProcess([], 0, b'{"Self":{"ID":"n1"}}')] + with patch.dict(client['connect'].__globals__, run=unittest.mock.Mock(side_effect=responses)), patch.object(subprocess, 'Popen'): + self.assertEqual(client['connect'](self.root, self.root / 'enrol-key', 'fixture')['ID'], 'n1') + self.assertNotIn('fixture-not-a-key', (self.root / 'client.json').read_text()) + for value in (True, None, 0, 'false'): + broken = json.dumps(dict(prefs, CorpDNS=value)).encode() + results = [subprocess.CompletedProcess([], 0, b'{}'), subprocess.CompletedProcess([], 0, b''), + subprocess.CompletedProcess([], 0, broken)] + with self.subTest(value=value), patch.dict(client['connect'].__globals__, run=unittest.mock.Mock(side_effect=results)), patch.object(subprocess, 'Popen'): + with self.assertRaisesRegex(NetworkError, 'still enabled'): + client['connect'](self.root, self.root / 'enrol-key', 'fixture') + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/versions.py b/.github/scripts/versions.py new file mode 100644 index 000000000..1de837c97 --- /dev/null +++ b/.github/scripts/versions.py @@ -0,0 +1,79 @@ +import argparse +import os +from pathlib import Path +import signal +import subprocess +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'testsuite')) +from harness.profile import load_binaries + + +def main(): + parser = argparse.ArgumentParser(description='Run historical-peer checks') + parser.add_argument('--role', choices=('primary', 'secondary'), required=True) + parser.add_argument('--current', default='rsync', help='Current rsync executable') + parser.add_argument('--output', default='test-results/version-mix', help='Result directory') + args = parser.parse_args() + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + archive = Path('old_versions') + records = load_binaries(archive) + current = Path(args.current).resolve(strict=True) + status = 0 + active = None + + def cancel(signum, frame): + signal.signal(signal.SIGTERM, signal.SIG_IGN) + signal.signal(signal.SIGINT, signal.SIG_IGN) + if active is not None and active.poll() is None: + active.terminate() + try: + active.wait(timeout=10) + except subprocess.TimeoutExpired: + active.kill() + active.wait(timeout=5) + raise SystemExit(128 + signum) + + previous = {sig: signal.signal(sig, cancel) for sig in (signal.SIGTERM, signal.SIGINT)} + try: + for name in sorted(records, key=lambda value: tuple( + int(part) for part in value[len('rsync_'):].split('.'))): + peer = archive / name + peer.chmod(peer.stat().st_mode | 0o111) + version = peer.name[len('rsync_'):] + profile = 'peer-' + version + primary, secondary = ((peer.resolve(), current) if args.role == 'primary' + else (current, peer.resolve())) + for transport in ('pipe', 'tcp'): + label = f'{peer.name} ({args.role}, {transport})' + profiles = ['non-asan', 'nonroot', 'linux', profile] + if transport == 'pipe': + profiles.insert(0, 'pipe') + print(f'::group::{label}', flush=True) + try: + command = [sys.executable, '-S', 'testsuite/tools/check.py', '.', str(primary), + ','.join(profiles), + str(output / f'{peer.name}-{args.role}-{transport}'), + f'--rsync-bin2={secondary}', f'--archived-role={args.role}', '-j8'] + if transport == 'tcp': + command += ['--use-tcp', '--daemon-tests-only'] + active = subprocess.Popen(command, env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'}) + if active.wait(): + print(f'::error::Version mix failed: {label}', file=sys.stderr) + status = 1 + finally: + active = None + print('::endgroup::', flush=True) + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + return status + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.SubprocessError) as error: + print(f'Version mix failed: {error}', file=sys.stderr) + raise SystemExit(1) diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml deleted file mode 100644 index 8d30e4096..000000000 --- a/.github/workflows/actionlint.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Lint GitHub Actions workflows - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Static-check the workflow YAML with rhysd/actionlint. Catches missing -# secrets, bad expressions, expression-type errors, unsupported runner -# images, and (via embedded shellcheck) common pitfalls in `run:` scripts. -# Trigger only on changes under .github/workflows/ so the rest of the -# matrix isn't billed when nothing here moves. - -on: - push: - branches: [ master ] - paths: - - '.github/workflows/*.yml' - - '.github/actionlint.yaml' - - '.github/actionlint.yml' - pull_request: - types: [opened, synchronize, reopened] - paths: - - '.github/workflows/*.yml' - - '.github/actionlint.yaml' - - '.github/actionlint.yml' - -permissions: - contents: read - -jobs: - actionlint: - runs-on: ubuntu-latest - name: actionlint - steps: - - uses: actions/checkout@v4 - - name: install actionlint - # Pin a version so this job is reproducible; bump deliberately. - # The download script verifies a SHA256 of the release tarball. - run: | - bash <(curl --proto '=https' --tlsv1.2 -fsSL \ - https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) \ - 1.7.12 - echo "$PWD" >>"$GITHUB_PATH" - - name: actionlint --version - run: actionlint -version - - name: actionlint .github/workflows/*.yml - run: actionlint -color diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml deleted file mode 100644 index 8eb4598c3..000000000 --- a/.github/workflows/almalinux-8-build.yml +++ /dev/null @@ -1,88 +0,0 @@ -name: Test rsync on AlmaLinux 8 - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Older-LTS coverage on the Fedora/RHEL family to help with backporting -# security fixes. AlmaLinux 8 is the RHEL 8 rebuild and is the oldest -# active LTS in this family (RHEL 8 full support runs to 2029). -# GitHub Actions has no native runner for this family, so the job runs -# inside an almalinux:8 container hosted on ubuntu-latest. - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/almalinux-8-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/almalinux-8-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: ubuntu-latest - container: - image: almalinux:8 - name: Test rsync on AlmaLinux 8 - steps: - - name: install git - # actions/checkout needs git in the container before the checkout step. - run: dnf -y install git - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - # PowerTools is needed for libzstd-devel etc; xxhash and lz4 dev - # headers live in EPEL on RHEL 8. The default python3 on RHEL 8 - # is 3.6, which is too old for runtests.py (uses capture_output= - # / text= introduced in 3.7), so install python39 and point - # /usr/bin/python3 at it. - run: | - dnf -y install epel-release - dnf config-manager --set-enabled powertools - dnf -y install gcc gcc-c++ make autoconf automake m4 \ - python39 python39-pip diffutils \ - openssl openssl-devel \ - attr libattr-devel acl libacl-devel \ - zstd libzstd-devel \ - lz4 lz4-devel \ - xxhash xxhash-devel \ - libidn2 libidn2-devel - alternatives --set python3 /usr/bin/python3.9 - pip3 install commonmark - - name: configure - run: ./configure --with-rrsync - - name: make - run: make - - name: info - run: ./rsync --version - - name: check - # In the container we already run as root, so no sudo. The - # crtimes-not-supported skip matches the other Linux jobs; - # daemon-chroot-acl and proxy-response-line-too-long skip because - # the default (secure) transport opens no listening socket. - run: RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check - - name: check (TCP daemon transport) - # Second run exercising the real loopback-TCP daemon path. - run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - - name: ssl file list - run: ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: almalinux-8-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/android-static-build.yml b/.github/workflows/android-static-build.yml deleted file mode 100644 index 14593a6dc..000000000 --- a/.github/workflows/android-static-build.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: Build static rsync for Android - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Cross-compiles statically-linked rsync binaries with the Android NDK, -# suitable for dropping onto a phone (adb push / Termux) with no shared -# libraries. arm64-v8a covers all modern phones; armeabi-v7a covers older -# 32-bit devices. The binaries are uploaded as workflow artifacts. -# -# These are cross-compiled, so the test suite can't run here; we sanity -# check that each binary is the right architecture, is static, and that -# it executes (`--version`) under qemu-user. - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/android-static-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/android-static-build.yml' - schedule: - - cron: '42 8 * * 1' - workflow_dispatch: - -env: - # Minimum supported API level. 24 (Android 7.0) runs on every modern - # phone while keeping broad reach; bump if you need newer Bionic APIs. - ANDROID_API: 24 - -jobs: - build: - runs-on: ubuntu-latest - name: ${{ matrix.abi }} - strategy: - fail-fast: false - matrix: - include: - - abi: arm64-v8a # modern phones - triple: aarch64-linux-android - qemu: qemu-aarch64-static - - abi: armeabi-v7a # older 32-bit phones - triple: armv7a-linux-androideabi - qemu: qemu-arm-static - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install build prerequisites - run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk qemu-user-static - - - name: Configure and build (${{ matrix.abi }}) - shell: bash - run: | - set -euo pipefail - NDK="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_ROOT}" - TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" - export CC="$TC/${{ matrix.triple }}${ANDROID_API}-clang" - export AR="$TC/llvm-ar" RANLIB="$TC/llvm-ranlib" STRIP="$TC/llvm-strip" - export CFLAGS="-O2" LDFLAGS="-static" - - # Bionic doesn't declare lchmod()/lutimes() until API 36, but the - # symbols link, so configure mis-detects them -- force them off so - # rsync uses its fallbacks. The other cache vars restore values - # that configure can't probe when cross-compiling (Android runs a - # normal Linux kernel, so these match the native Linux result). - export ac_cv_func_lchmod=no ac_cv_func_lutimes=no \ - rsync_cv_HAVE_SOCKETPAIR=yes \ - rsync_cv_MKNOD_CREATES_FIFOS=yes \ - rsync_cv_MKNOD_CREATES_SOCKETS=yes - - # Self-contained build: drop optional external libraries so the - # static binary needs nothing at runtime. rsync keeps md5/md4 - # checksums and its bundled zlib. - ./configure --host=${{ matrix.triple }} --build=x86_64-pc-linux-gnu \ - --enable-ipv6 \ - --disable-zstd --disable-lz4 --disable-xxhash --disable-openssl --disable-idn \ - --disable-iconv --disable-iconv-open \ - --disable-acl-support --disable-xattr-support \ - --disable-md2man --disable-roll-simd \ - --with-included-popt --with-included-zlib - - # Generate the awk-built headers serially first so the parallel - # build can't race on proto.h <- daemon-parm.h. - make proto.h - make -j"$(nproc)" rsync - "$STRIP" rsync - - - name: Verify binary - shell: bash - run: | - set -euo pipefail - file rsync - # Gate: must be a statically-linked executable (no interpreter). - file rsync | grep -q "statically linked" - if file rsync | grep -q "dynamically linked"; then - echo "ERROR: binary is not static" >&2; exit 1 - fi - # Best-effort: confirm it actually runs under qemu-user. - ${{ matrix.qemu }} ./rsync --version | head -3 || \ - echo "WARNING: qemu smoke test did not run cleanly (check on a real device)" - - - name: Package - shell: bash - run: | - set -euo pipefail - VER=$(sed -n 's/.*RSYNC_VERSION "\([^"]*\)".*/\1/p' version.h) - out="rsync-${VER}-android-${{ matrix.abi }}" - mkdir -p dist - cp rsync "dist/$out" - ( cd dist && sha256sum "$out" > "$out.sha256" ) - echo "ARTIFACT_NAME=rsync-android-${{ matrix.abi }}" >>"$GITHUB_ENV" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: ${{ env.ARTIFACT_NAME }} - path: dist/ diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 000000000..4ac0137c8 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,170 @@ +name: Android + +on: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + inputs: + description: Job selections + value: '{"bionic": true, "x86_64": true, "termux": true, "emulator": true}' + +permissions: + contents: read + +defaults: + run: + shell: sh + +env: + ANDROID_API: 24 + +jobs: + bionic: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: ${{ matrix.name }} + env: + ANDROID_TRIPLE: ${{ matrix.triple }} + strategy: + fail-fast: false + matrix: + include: + - name: Bionic API 24 ARM64 + abi: arm64-v8a + triple: aarch64-linux-android + executor: qemu-aarch64-static + packages: qemu-user-static + - name: Bionic API 24 ARMv7 + abi: armeabi-v7a + triple: armv7a-linux-androideabi + executor: qemu-arm-static + packages: qemu-user-static + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk ${{ matrix.packages }} + + - &build + name: Build + run: | + set -eu + NDK="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_ROOT}" + TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" + export CC="$TC/${ANDROID_TRIPLE}${ANDROID_API}-clang" + export CXX="$TC/${ANDROID_TRIPLE}${ANDROID_API}-clang++" + export AR="$TC/llvm-ar" RANLIB="$TC/llvm-ranlib" STRIP="$TC/llvm-strip" + export CFLAGS="-O2" LDFLAGS="-static" + + # Bionic hides lchmod and lutimes before API 36; cross-builds cannot run probes + export ac_cv_func_lchmod=no ac_cv_func_lutimes=no \ + rsync_cv_HAVE_SOCKETPAIR=yes \ + rsync_cv_MKNOD_CREATES_FIFOS=yes \ + rsync_cv_MKNOD_CREATES_SOCKETS=yes + + ./configure --host="$ANDROID_TRIPLE" --build=x86_64-pc-linux-gnu \ + --enable-ipv6 \ + --disable-zstd --disable-lz4 --disable-xxhash --disable-openssl --disable-idn \ + --disable-iconv --disable-iconv-open \ + --disable-acl-support --disable-xattr-support \ + --disable-md2man --disable-roll-simd \ + --with-included-popt --with-included-zlib + + # Generate shared headers before the parallel build + make proto.h + make -j"$(nproc)" rsync + "$STRIP" rsync + + - name: Tests + run: python3 -S .github/scripts/android.py test --executor "${{ matrix.executor }}" + + - name: Package + run: python3 -S .github/scripts/android.py package --abi "${{ matrix.abi }}" + + - &upload + name: Upload binaries + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: ${{ env.ARTIFACT_NAME }} + if-no-files-found: error + path: dist/ + + x86_64: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Bionic API 24 X86-64 + env: + ANDROID_TRIPLE: x86_64-linux-android + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y autoconf automake gawk + - *build + - name: Tests + run: python3 -S .github/scripts/android.py test --executor direct + - name: Package + run: python3 -S .github/scripts/android.py package --abi x86_64 + - *upload + + termux: + needs: x86_64 + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Termux X86-64 + env: + TERMUX_IMAGE: termux/termux-docker@sha256:f993e5e7ef97b96af774e7062556ac51479fdccc3c3d2240748a3d04b857ce04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - &download + name: Download binary + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: rsync-android-x86_64 + path: dist/ + - &restore + name: Verify binary + run: python3 -S .github/scripts/android.py restore --abi x86_64 + - name: Tests + run: python3 -S .github/scripts/android.py termux + + emulator: + needs: x86_64 + runs-on: ubuntu-24.04 + timeout-minutes: 30 + name: Emulator API 36 X86-64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y acl libpulse0 + sudo setfacl -m "u:$(id -un):rw" /dev/kvm + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'system-images;android-36;google_apis;x86_64' emulator + - *download + - *restore + - name: Tests + id: test + run: python3 -S .github/scripts/android-emulator.py + - name: Upload results + if: ${{ !cancelled() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: android-emulator-results + if-no-files-found: error + path: test-results/android-emulator/ diff --git a/.github/workflows/archive.yml b/.github/workflows/archive.yml new file mode 100644 index 000000000..2214bb040 --- /dev/null +++ b/.github/workflows/archive.yml @@ -0,0 +1,49 @@ +name: Archive + +on: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + archive: + name: Ubuntu 24.04 X86-64 + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y autoconf automake gawk fakeroot acl attr libacl1-dev libattr1-dev libxxhash-dev libzstd-dev liblz4-dev libidn2-dev libpopt-dev libssl-dev python3-cmarkgfm openssl + - name: Prepare source + run: ./prepare-source + - name: Build + run: | + ./configure --with-rrsync + make -j2 + make gen + - name: Create archive + run: python3 -B -S .github/scripts/archive.py create --archive test-results/source.tar.gz + - name: Qualify archive + run: python3 -B -S .github/scripts/archive.py test --archive test-results/source.tar.gz --output test-results/archive + - name: Upload results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: source-archive-results + retention-days: 45 + if-no-files-found: error + path: test-results/ diff --git a/.github/workflows/asan-build.yml b/.github/workflows/asan-build.yml deleted file mode 100644 index 246a31f9d..000000000 --- a/.github/workflows/asan-build.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: rsync ASan+UBSan (clang) - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/asan-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/asan-build.yml' - schedule: - # Weekly (Mon 09:42 UTC): catch breakage from a moving ubuntu-latest/clang - # toolchain (a new clang can add a UBSan check, or change ASan behaviour) - # that no code push would otherwise trigger. Push/PR already gate every - # code change, so daily would just re-run an unchanged tree. - - cron: '42 9 * * 1' - workflow_dispatch: - -jobs: - asan: - runs-on: ubuntu-latest - name: rsync ASan+UBSan (clang) - env: - # rsync intentionally leaks small allocations at process exit, so leak - # detection would be all noise; chase only memory-safety errors. - ASAN_OPTIONS: detect_leaks=0:abort_on_error=1 - # UBSan is a gate: -fno-sanitize-recover=undefined (below) aborts on the - # first finding and halt_on_error=1 makes that fatal, so any undefined - # behaviour fails the run. This needs the tree to be UBSan-clean: the - # remaining findings are fixed in code (hashtable/mdfour shifts, xattrs, - # and log.c's file_struct, kept aligned via rounding.h); only byteorder.h's - # intentional unaligned accessors are suppressed, with no_sanitize. - UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - # -DNDEBUG builds as a shipped release does (assert() compiled out), so - # AddressSanitizer catches the over-reads/over-writes that an "assert() - # instead of a real bounds check" bug would cause in a production build. - # UBSan rides along on the same build; -fno-sanitize-recover=undefined - # makes any undefined behaviour abort (and thus fail the run) instead of - # merely printing it. - run: | - CC=clang \ - CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \ - LDFLAGS="-fsanitize=address,undefined" \ - ./configure --with-rrsync --disable-md2man - - name: make - # check-progs builds rsync plus the test helper programs (tls, trimslash, - # t_unsafe, ...) that runtests.py requires; plain "make" builds only rsync - # and runtests aborts on the missing helpers. - run: make check-progs - - name: info - run: ./rsync --version - - name: check (stdio-pipe transport) - # ASan+UBSan-instrumented coverage of the transfer, daemon, sender, - # receiver and metadata paths over the default stdio-pipe transport. - run: ./runtests.py --rsync-bin="$PWD/rsync" -j8 - - name: check (TCP daemon transport) - # --use-tcp also exercises the loopback rsyncd listener and the client's - # TCP connection path. - run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j8 diff --git a/.github/workflows/bsd.yml b/.github/workflows/bsd.yml new file mode 100644 index 000000000..d85f9806a --- /dev/null +++ b/.github/workflows/bsd.yml @@ -0,0 +1,206 @@ +name: BSD + +on: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + freebsd: + strategy: + fail-fast: false + matrix: + release: ['14.5', '15.1'] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: FreeBSD ${{ matrix.release }} X86-64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare VM host + timeout-minutes: 10 + run: | + set -- qemu-utils qemu-system-x86 ovmf + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" + } + - name: Run VM and collect results + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 + with: + release: ${{ matrix.release }} + cache-after-prepare: true + prepare: | + pkg install -y bash autotools m4 xxhash zstd liblz4 libidn2 libiconv python3 git + /usr/sbin/pw groupadd rsynctest + /usr/sbin/pw groupadd rsynctest-extra + /usr/sbin/pw useradd rsynctest -g rsynctest -G rsynctest-extra -m -d /home/rsynctest -s /bin/sh -w no + run: python3 -S .github/scripts/guest.py freebsd --release "${{ matrix.release }}" + - name: Verify guest results + run: python3 -S .github/scripts/guest.py check + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: freebsd-${{ matrix.release }}-amd64-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: freebsd-${{ matrix.release }}-amd64-results + if-no-files-found: error + path: | + config.log + test-results/status + test-results/features.json + test-results/nonroot/dirfd-probe.tsv + test-results/*/*/receipt.json + test-results/*/*/run.log + + netbsd: + strategy: + fail-fast: false + matrix: + release: ['10.2', '11.0', '11.1'] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: NetBSD ${{ matrix.release }} X86-64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare VM host + timeout-minutes: 10 + run: | + set -- qemu-utils qemu-system-x86 ovmf + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" + } + - name: Run VM and collect results + uses: vmactions/netbsd-vm@5957cf330aef2e6ddc63c604364c99ce2e02b91d # v1.5.3 + with: + release: ${{ matrix.release }} + cache-after-prepare: true + prepare: | + PATH=/usr/sbin:$PATH pkg_add autoconf automake git-base python312 + ln -sf /usr/pkg/bin/python3.12 /usr/pkg/bin/python3 + /usr/sbin/groupadd rsynctest-extra + /usr/sbin/useradd -m -d /home/rsynctest -g =uid -G rsynctest-extra -s /bin/sh rsynctest + run: python3 -S .github/scripts/guest.py netbsd --release "${{ matrix.release }}" + - name: Verify guest results + run: python3 -S .github/scripts/guest.py check + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: netbsd-${{ matrix.release }}-amd64-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: netbsd-${{ matrix.release }}-amd64-results + if-no-files-found: error + path: | + config.log + test-results/status + test-results/nonroot/dirfd-probe.tsv + test-results/*/*/receipt.json + test-results/*/*/run.log + + openbsd: + strategy: + fail-fast: false + matrix: + release: ['7.9', '8.0'] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: OpenBSD ${{ matrix.release }} X86-64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare VM host + timeout-minutes: 10 + run: | + set -- qemu-utils qemu-system-x86 ovmf + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" + } + - name: Run VM and collect results + uses: vmactions/openbsd-vm@f1ca8bc8ecc5b6ef7218e5b914836e067e1546d3 # v1.4.9 + with: + release: ${{ matrix.release }} + cache-after-prepare: true + prepare: | + pkg_add -I bash autoconf%2.71 automake%1.16 xxhash zstd lz4 libidn2 libiconv git + /usr/sbin/groupadd rsynctest-extra + /usr/sbin/useradd -m -d /home/rsynctest -g =uid -G rsynctest-extra -s /bin/sh rsynctest + run: python3 -S .github/scripts/guest.py openbsd --release "${{ matrix.release }}" + - name: Verify guest results + run: python3 -S .github/scripts/guest.py check + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: openbsd-${{ matrix.release }}-amd64-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: openbsd-${{ matrix.release }}-amd64-results + if-no-files-found: error + path: | + config.log + test-results/status + test-results/features.json + test-results/nonroot/dirfd-probe.tsv + test-results/*/*/receipt.json + test-results/*/*/run.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..0e0177f84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + schedule: + - cron: '42 8 * * 0,2-6' + - cron: '42 8 * * 1' + - cron: '52 8 * * 1' + - cron: '42 9 * * 1' + - cron: '17 4 * * *' + +permissions: + contents: read + +defaults: + run: + shell: sh + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + interoperability: + name: Interoperability + if: &interoperability ${{ github.event_name != 'schedule' }} + uses: ./.github/workflows/interoperability-tests.yml + permissions: + contents: read + actions: read + + required: + name: Required + if: always() + needs: [interoperability] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + CI_EXPECT_INTEROPERABILITY: *interoperability + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check results + env: + CI_RESULTS: ${{ toJSON(needs) }} + run: python3 -S .github/scripts/check-results.py + - name: Full CI required + run: python3 -c "raise SystemExit('Full CI is temporarily disabled')" diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 6e0eccaff..000000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Coverage (Ubuntu) - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/coverage.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/coverage.yml' - schedule: - - cron: '42 9 * * 1' - workflow_dispatch: - -jobs: - coverage: - runs-on: ubuntu-latest - name: gcov coverage - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl gcovr - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --enable-coverage --with-rrsync - - name: make - run: make - - name: info - run: rsync --version - # Two coverage runs: the default pipe transport, then a second pass over a - # real loopback rsyncd (--use-tcp) which also exercises the require_tcp-only - # tests. gcovr's --print-summary line/branch/decision totals go to the step - # log (and the job summary below), so the numbers are visible in CI. - # `make coverage` exits with the suite's status, so a regression fails CI. - - name: coverage (pipe transport) - run: | - set -o pipefail - sudo make coverage 2>&1 | tee cov-pipe.log - - name: coverage (TCP transport) - run: | - set -o pipefail - sudo make coverage-tcp 2>&1 | tee cov-tcp.log - - name: coverage summary - if: always() - run: | - { - echo "## gcov coverage" - echo "### Pipe transport (\`make coverage\`)" - echo '```' - grep -E '^(lines|functions|branches|decisions):' cov-pipe.log || echo '(no summary -- see step log)' - echo '```' - echo "### TCP transport (\`make coverage-tcp\`)" - echo '```' - grep -E '^(lines|functions|branches|decisions):' cov-tcp.log || echo '(no summary -- see step log)' - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - name: upload HTML reports - if: always() - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: coverage-html - path: | - coverage - coverage-tcp diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml deleted file mode 100644 index b82e18b12..000000000 --- a/.github/workflows/cygwin-build.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Test rsync on Cygwin - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/cygwin-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/cygwin-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: windows-2022 - name: Test rsync on Cygwin - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: cygwin - run: | - $setup = Join-Path $Env:RUNNER_TEMP 'setup-x86_64.exe' - $sums = Join-Path $Env:RUNNER_TEMP 'cygwin-sha512.sum' - $packages = 'make,autoconf,automake,gcc-core,attr,libattr-devel,python39,python39-pip,libzstd-devel,liblz4-devel,libssl-devel,libxxhash0,libxxhash-devel,libidn2-devel' - - Invoke-WebRequest https://cygwin.com/setup-x86_64.exe -OutFile $setup - Invoke-WebRequest https://cygwin.com/sha512.sum -OutFile $sums - - $sum = Select-String -LiteralPath $sums -Pattern '^[0-9a-fA-F]{128}\s+\*?setup-x86_64\.exe$' | Select-Object -First 1 - if (-not $sum) { - throw 'setup-x86_64.exe is missing from Cygwin sha512.sum' - } - $expected = ($sum.Line -split '\s+')[0] - $actual = (Get-FileHash -LiteralPath $setup -Algorithm SHA512).Hash - if ($actual -ine $expected) { - throw 'Cygwin setup SHA-512 mismatch' - } - - $arguments = @( - '--quiet-mode', - '--no-desktop', - '--no-startmenu', - '--no-shortcuts', - '--root', 'C:\tools\cygwin', - '--local-package-dir', (Join-Path $Env:RUNNER_TEMP 'cygwin-packages'), - '--site', 'https://mirrors.kernel.org/sourceware/cygwin/', - '--packages', $packages - ) - $install = Start-Process -FilePath $setup -ArgumentList $arguments -Wait -PassThru -NoNewWindow - if ($install.ExitCode -ne 0) { - exit $install.ExitCode - } - - $bash = 'C:\tools\cygwin\bin\bash.exe' - if (-not (Test-Path -LiteralPath $bash)) { - throw 'Cygwin setup did not install bash' - } - & $bash -lc 'command -v make aclocal gcc python3 >/dev/null' - if ($LASTEXITCODE -ne 0) { - throw 'Cygwin setup did not install all required build tools' - } - - echo 'C:/tools/cygwin/bin' >>$Env:GITHUB_PATH - - name: commonmark - run: bash -c 'python3 -mpip install --user commonmark' - - name: configure - run: bash -c './configure --with-rrsync' - - name: make - run: bash -c 'make' - - name: install - run: bash -c 'make install' - - name: info - run: bash -c '/usr/local/bin/rsync --version' - - name: check - # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on Cygwin - # (rsyncfns.py drives xattrs via getfattr/setfattr from the `attr` - # package installed above), verified on a real Cygwin host. The real - # chown/devices tests still skip (need root/mknod), as do the - # RESOLVE_BENEATH symlink-race tests. Cygwin runs non-root, so the - # namecvt empty-response regression can run despite its common root skip. - run: bash -c 'RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/cygwin.txt,-daemon-namecvt-empty-response make check' - - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: bash -c './runtests.py --rsync-bin=`pwd`/rsync.exe --use-tcp -j 8' - - name: ssl file list - run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true' - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: cygwin-bin - path: | - rsync.exe - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/diagnostics.yml b/.github/workflows/diagnostics.yml new file mode 100644 index 000000000..ab80c1ce0 --- /dev/null +++ b/.github/workflows/diagnostics.yml @@ -0,0 +1,251 @@ +name: Diagnostics + +on: + workflow_call: + inputs: + lint: + required: true + type: boolean + python_37: + required: true + type: boolean + asan: + required: true + type: boolean + coverage: + required: true + type: boolean + clang_18: + required: true + type: boolean + clang_21: + required: true + type: boolean + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + inputs: + description: Job selections + value: ${{ toJSON(inputs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + python_37: + if: ${{ inputs.python_37 }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + name: Python 3.7 + container: python:3.7.17-bullseye@sha256:f36f6fe9ddb2622aab9803944cbe7e2bc50be75d624a081f7895e60e7c6b7f0a + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Configure + run: ./configure --disable-md2man --disable-openssl --disable-xxhash --disable-zstd --disable-lz4 --disable-idn --disable-acl-support --disable-xattr-support --with-included-popt + - name: Build + run: make -j2 check-progs + - name: Check helpers + run: | + python3 -B -S .github/scripts/test-android.py + python3 -B -S .github/scripts/test-results.py + python3 -B -S .github/scripts/test-guest.py + python3 -B -S .github/scripts/test-diagnostics.py + python3 -B -S .github/scripts/test-archive.py + python3 -B -S .github/scripts/test-interop-policy.py + python3 -B -S .github/scripts/test-interop.py + python3 -B -S .github/scripts/test-transfers.py + - name: Tests + id: test + run: python3 -B -S testsuite/tools/check.py . ./rsync pipe,root,self-peer,linux,non-asan,no-userns test-results/python37 metadata profile process cli-transfers runner check-recipe -j2 + - name: Upload results + if: ${{ !cancelled() && (steps.test.outcome == 'success' || steps.test.outcome == 'failure') }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: python37-results + retention-days: 45 + if-no-files-found: error + path: test-results/python37/ + + lint: + if: ${{ inputs.lint }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + name: Workflow lint + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install actionlint + run: python3 -S .github/scripts/diagnostics.py install-actionlint + - name: Version + run: actionlint -version + - name: Check workflows + run: actionlint -color -ignore '^label "ubuntu-26\.04(-arm)?" is unknown\.' + - name: Check result gate + run: python3 -S .github/scripts/test-results.py + - name: Check CI helpers + run: | + python3 -S .github/scripts/test-guest.py + python3 -S .github/scripts/test-diagnostics.py + python3 -S .github/scripts/test-android.py + python3 -S .github/scripts/test-archive.py + python3 -S .github/scripts/test-interop-policy.py + python3 -S .github/scripts/test-interop.py + python3 -S .github/scripts/test-transfers.py + - name: Check Python runtime + run: python3 -S testsuite/runtests.py --describe-tests > /dev/null + + asan: + if: ${{ inputs.asan }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: ASan + UBSan + env: + # Process-exit leaks are intentional; memory errors remain fatal + ASAN_OPTIONS: detect_leaks=0:abort_on_error=1 + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev openssl + echo "/usr/local/bin" >>"$GITHUB_PATH" + - name: Configure + # Exercise release behaviour with assertions disabled + run: | + CC=clang \ + CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1 -DNDEBUG" \ + LDFLAGS="-fsanitize=address,undefined" \ + ./configure --with-rrsync --disable-md2man + - name: Build + id: build + run: make check-progs + - name: Version + run: ./rsync --version + - name: Tests (non-root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py . ./rsync pipe,nonroot,self-peer,linux,asan,no-userns test-results/asan-pipe -j8 + - name: Tests (non-root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py . ./rsync nonroot,self-peer,linux,asan,no-userns test-results/asan-tcp --use-tcp --daemon-tests-only -j8 + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: asan-results + if-no-files-found: error + path: | + test-results/*/receipt.json + test-results/*/run.log + + coverage: + if: ${{ inputs.coverage }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Coverage + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl gcovr + echo "/usr/local/bin" >>"$GITHUB_PATH" + - name: Configure + run: ./configure --enable-coverage --with-rrsync + - name: Build + id: build + run: make + - name: Version + run: ./rsync --version + - name: Coverage (root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo env RSYNC_TEST_PROFILES=pipe,non-asan,root,self-peer,linux,no-userns python3 -S .github/scripts/diagnostics.py coverage pipe + - name: Coverage (root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo env RSYNC_TEST_PROFILES=non-asan,root,self-peer,linux,no-userns python3 -S .github/scripts/diagnostics.py coverage tcp + - name: Coverage summary + if: always() + run: python3 -S .github/scripts/diagnostics.py coverage-summary + - name: Upload reports + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: coverage-html + if-no-files-found: error + path: | + coverage + coverage-tcp + + clang_18: + if: ${{ inputs.clang_18 }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Clang 18 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 clang-tools-18 acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev libpopt-dev openssl + - name: Version + run: clang-18 --version + - name: Configure + run: scan-build-18 ./configure --with-rrsync --disable-md2man + - name: Analyse + run: python3 -S .github/scripts/diagnostics.py scan scan-build-18 + - name: Upload report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: scan-build-report-clang18 + path: scan-report + if-no-files-found: ignore + + clang_21: + if: ${{ inputs.clang_21 }} + runs-on: ubuntu-26.04 + timeout-minutes: 45 + name: Clang 21 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-21 clang-tools-21 acl libacl1-dev attr libattr1-dev liblz4-dev libidn2-dev libzstd-dev libxxhash-dev libpopt-dev openssl + - name: Version + run: clang-21 --version + - name: Configure + run: scan-build-21 ./configure --with-rrsync --disable-md2man + - name: Analyse + run: python3 -S .github/scripts/diagnostics.py scan scan-build-21 + - name: Upload report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: scan-build-report-clang21 + path: scan-report + if-no-files-found: ignore diff --git a/.github/workflows/fleettest.yml b/.github/workflows/fleettest.yml deleted file mode 100644 index 40f6f8fc6..000000000 --- a/.github/workflows/fleettest.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Test fleettest harness - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Bitrot check for testsuite/fleettest.py (the developer fleet CI harness). -# fleettest is meant to be run by developers on a modern Ubuntu box, so this -# job runs only on ubuntu-latest: it stands up a one-host "fleet" of two -# targets that both ssh to localhost and runs a real fleettest pass against it. -# It does not run on the BSD/Solaris/macOS/Cygwin matrix. - -on: - push: - branches: [ master ] - paths: - - 'testsuite/fleettest.py' - - '.github/workflows/fleettest.yml' - - 'runtests.py' - - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'testsuite/fleettest.py' - - '.github/workflows/fleettest.yml' - - 'runtests.py' - - 'testsuite/skiplist/**' - - 'testsuite/skiplist-spec_test.py' - workflow_dispatch: - schedule: - - cron: '17 7 * * 1' - -jobs: - fleettest: - runs-on: ubuntu-latest - name: fleettest against localhost - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y gcc g++ gawk autoconf automake \ - acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev \ - python3-cmarkgfm openssl rsync openssh-server - - name: set up ssh to localhost - run: | - mkdir -p ~/.ssh && chmod 700 ~/.ssh - ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 - cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys - chmod 600 ~/.ssh/authorized_keys - sudo systemctl start ssh || sudo service ssh start - # fleettest connects with `ssh -o BatchMode=yes localhost`, which won't - # answer a host-key prompt -- so pre-trust localhost in known_hosts. - ssh-keyscan -H localhost 127.0.0.1 >> ~/.ssh/known_hosts 2>/dev/null - ssh -o BatchMode=yes -o ConnectTimeout=15 localhost 'echo ssh-to-localhost-ok' - - name: write localhost fleet config - run: | - cat > fleettest-ci.json <<'EOF' - { "targets": [ - { "name": "local-a", "ssh_host": "localhost", "workflow": "none.yml", - "configure_flags": [], "builddir": "rsync-citest-a", "privilege": "sudo" }, - { "name": "local-b", "ssh_host": "localhost", "workflow": "none.yml", - "configure_flags": [], "builddir": "rsync-citest-b", "privilege": "sudo" } - ] } - EOF - - name: fleettest --list (config sanity) - run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --list - - name: run fleettest against localhost - # Two targets both on localhost exercise the parallel multi-target path - # and the per-run dir / port isolation; exit 0 iff every cell is OK. - run: python3 testsuite/fleettest.py --fleet fleettest-ci.json --timing diff --git a/.github/workflows/freebsd-build.yml b/.github/workflows/freebsd-build.yml deleted file mode 100644 index fe87913f5..000000000 --- a/.github/workflows/freebsd-build.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Test rsync on FreeBSD - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/freebsd-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/freebsd-build.yml' - schedule: - - cron: '42 8 * * 1' - -jobs: - test: - runs-on: ubuntu-latest - name: Test rsync on FreeBSD - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Test in FreeBSD VM - id: test - uses: vmactions/freebsd-vm@v1 - with: - usesh: true - prepare: | - pkg install -y bash autotools m4 devel/xxhash zstd liblz4 python3 archivers/liblz4 git - run: | - freebsd-version - ./configure --with-rrsync \ - -disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn \ - --enable-roll-simd --enable-roll-asm --enable-md5-asm - make - ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: freebsd-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/interoperability-tests.yml b/.github/workflows/interoperability-tests.yml new file mode 100644 index 000000000..65be0afc5 --- /dev/null +++ b/.github/workflows/interoperability-tests.yml @@ -0,0 +1,198 @@ +name: Interoperability + +on: + workflow_call: + outputs: + jobs: + description: Native endpoint and result jobs + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + actions: read + +jobs: + linux: + name: Linux + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc make openssl openssh-server libacl1-dev libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev + sudo install -d -o root -g root -m 0755 /run/sshd + - name: Build + run: ./configure --disable-md2man && make -j2 + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: linux + + freebsd: + name: FreeBSD + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare VM host + run: sudo apt-get update && sudo apt-get install -y qemu-utils qemu-system-x86 ovmf + - name: Build in FreeBSD + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 + with: + release: '15.1' + copyback: 'false' + cache-after-prepare: true + prepare: pkg install -y bash autotools m4 xxhash zstd liblz4 libidn2 libiconv python3 git + run: CPPFLAGS=-I/usr/local/include LDFLAGS=-L/usr/local/lib ./configure --disable-md2man && make -j2 + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: freebsd + guest: 'true' + + netbsd: + name: NetBSD + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare VM host + run: sudo apt-get update && sudo apt-get install -y qemu-utils qemu-system-x86 ovmf + - name: Build in NetBSD + uses: vmactions/netbsd-vm@5957cf330aef2e6ddc63c604364c99ce2e02b91d # v1.5.3 + with: + release: '11.1' + copyback: 'false' + cache-after-prepare: true + prepare: | + PATH=/usr/sbin:$PATH pkg_add autoconf automake git-base python312 + ln -sf /usr/pkg/bin/python3.12 /usr/pkg/bin/python3 + run: ./configure --disable-md2man --disable-zstd --disable-xxhash --disable-lz4 --disable-idn && make -j2 + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: netbsd + guest: 'true' + + openbsd: + name: OpenBSD + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare VM host + run: sudo apt-get update && sudo apt-get install -y qemu-utils qemu-system-x86 ovmf + - name: Build in OpenBSD + uses: vmactions/openbsd-vm@f1ca8bc8ecc5b6ef7218e5b914836e067e1546d3 # v1.4.9 + with: + release: '8.0' + copyback: 'false' + cache-after-prepare: true + prepare: pkg_add -I bash autoconf%2.71 automake%1.16 xxhash zstd lz4 libidn2 libiconv git + run: | + export AUTOCONF_VERSION=2.71 AUTOMAKE_VERSION=1.16 + export CPPFLAGS='-I/usr/local/include -DXXH_INLINE_ALL=1' LDFLAGS=-L/usr/local/lib ac_cv_search_XXH64_createState='' + ./configure --disable-md2man + make -j2 + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: openbsd + guest: 'true' + + macos: + name: macOS + runs-on: macos-26 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.27.4' + cache: false + - name: Install dependencies + run: | + brew install automake openssl xxhash zstd lz4 libidn2 + echo "$(brew --prefix openssl)/bin" >>"$GITHUB_PATH" + - name: Build + run: | + prefix=$(brew --prefix) + openssl=$(brew --prefix openssl) + CPPFLAGS="-I$prefix/include -I$openssl/include" LDFLAGS="-L$prefix/lib -L$openssl/lib" ./configure --disable-md2man + make -j2 + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: macos + + solaris: + name: Solaris + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare VM host + run: sudo apt-get update && sudo apt-get install -y qemu-utils qemu-system-x86 ovmf + - name: Build in Solaris + uses: vmactions/solaris-vm@87d3c436511cef802cd1637f86f73b1a97715c8c # v1.4.1 + with: + copyback: 'false' + cache-after-prepare: true + prepare: pkg install bash automake gnu-m4 pkg://solaris/runtime/python-35 autoconf gcc git + run: ./configure --disable-md2man --disable-zstd --disable-xxhash --disable-lz4 --disable-idn && make + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: solaris + guest: 'true' + + cygwin: + name: Cygwin + runs-on: windows-2022 + timeout-minutes: 75 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install Cygwin + run: .github/scripts/install-cygwin.ps1 -OpenSSH + - name: Build + run: bash -c './configure --disable-md2man && make -j2' + - name: Transfers + uses: ./.github/actions/interoperability + with: + platform: cygwin + + result: + name: Result + if: always() + needs: [linux, freebsd, netbsd, openbsd, macos, solaris, cygwin] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Verify all endpoint results and cleanup + env: + GH_TOKEN: ${{ github.token }} + INTEROP_JOBS: ${{ toJSON(needs) }} + run: python3 -B -S .github/scripts/interop-host.py result all --root "$RUNNER_TEMP/interoperability" diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 000000000..eca59378f --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,286 @@ +name: Linux + +on: + workflow_call: + inputs: + ubuntu: + required: true + type: boolean + almalinux: + required: true + type: boolean + alpine: + required: true + type: boolean + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + inputs: + description: Job selections + value: ${{ toJSON(inputs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + ubuntu: + if: ${{ inputs.ubuntu }} + strategy: + fail-fast: false + matrix: + include: + - release: '26.04' + arch: amd64 + machine: x86_64 + runner: ubuntu-26.04 + profiles: non-asan,self-peer,linux,no-userns + - release: '24.04' + arch: amd64 + machine: x86_64 + runner: ubuntu-24.04 + profiles: non-asan,self-peer,linux,no-userns + - release: '22.04' + arch: amd64 + machine: x86_64 + runner: ubuntu-22.04 + profiles: non-asan,self-peer,linux,no-zstd-threads + - release: '26.04' + arch: arm64 + machine: aarch64 + runner: ubuntu-26.04-arm + profiles: non-asan,self-peer,linux,no-simd,no-userns + - release: '24.04' + arch: arm64 + machine: aarch64 + runner: ubuntu-24.04-arm + profiles: non-asan,self-peer,linux,no-simd,no-userns + - release: '22.04' + arch: arm64 + machine: aarch64 + runner: ubuntu-22.04-arm + profiles: non-asan,self-peer,linux,no-simd,no-zstd-threads + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + name: Ubuntu ${{ matrix.release }} ${{ matrix.arch == 'amd64' && 'X86-64' || 'ARM64' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Platform + run: python3 -S .github/scripts/check-platform.py ubuntu --release "${{ matrix.release }}" --arch "${{ matrix.machine }}" + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl openssh-server + sudo install -d -o root -g root -m 0755 /run/sshd + sudo sysctl -w fs.protected_regular=2 + echo "/usr/local/bin" >>"$GITHUB_PATH" + mkdir -p test-results + - name: Configure + run: ./configure --with-rrsync + - name: Build + id: build + run: make check-progs + - name: Installation check + run: python3 -S .github/scripts/installcheck.py + - name: Install + run: sudo make install + - name: Version + run: ./rsync --version + - name: Verify installed rsync-ssl + run: | + python3 -S testsuite/tools/check-rsync-ssl.py \ + --rsync /usr/local/bin/rsync \ + --rsync-ssl /usr/local/bin/rsync-ssl \ + --openssl /usr/bin/openssl + - name: Verify restricted SSH + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check-ssh.py --rsync /usr/local/bin/rsync --rrsync support/rrsync + - name: Tests (root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,root,${{ matrix.profiles }}" "$PWD/test-results/root/pipe" -j8 + - name: Tests (protocol 30) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,root,${{ matrix.profiles }},protocol-30" "$PWD/test-results/root/protocol-30" --protocol=30 -j8 + - name: Tests (protocol 29) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,root,${{ matrix.profiles }},protocol-29" "$PWD/test-results/root/protocol-29" --protocol=29 -j8 + - name: Tests (root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "root,${{ matrix.profiles }}" "$PWD/test-results/root/tcp" --use-tcp --daemon-tests-only -j8 + - name: Tests (non-root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,nonroot,${{ matrix.profiles }}" "$PWD/test-results/nonroot/pipe" -j8 + - name: Tests (non-root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "nonroot,${{ matrix.profiles }}" "$PWD/test-results/nonroot/tcp" --use-tcp --daemon-tests-only -j8 + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: ubuntu-${{ matrix.release }}-${{ matrix.arch }}-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: ubuntu-${{ matrix.release }}-${{ matrix.arch }}-results + if-no-files-found: error + path: | + config.log + test-results/*/*/receipt.json + test-results/*/*/run.log + + almalinux: + if: ${{ inputs.almalinux }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + container: + image: almalinux:8 + name: AlmaLinux 8 X86-64 + steps: + - name: Install Git + run: dnf -y install git + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + # RHEL 8's default Python is too old for the test runner + dnf -y install epel-release + dnf config-manager --set-enabled powertools + dnf -y install gcc gcc-c++ make autoconf automake m4 \ + python39 python39-pip diffutils \ + openssl openssl-devel \ + attr libattr-devel acl libacl-devel \ + zstd libzstd-devel \ + lz4 lz4-devel \ + xxhash xxhash-devel \ + libidn2 libidn2-devel + alternatives --set python3 /usr/bin/python3.9 + pip3 install commonmark + - name: Platform + run: python3 -S .github/scripts/check-platform.py almalinux --release 8 --arch x86_64 + - name: Configure + run: ./configure --with-rrsync + - name: Build + id: build + run: make check-progs + - name: Version + run: ./rsync --version + - name: Tests (root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" pipe,non-asan,root,self-peer,linux,no-zstd-threads,no-userns "$PWD/test-results/root/pipe" -j8 + - name: Tests (root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" non-asan,root,self-peer,linux,no-zstd-threads,no-userns "$PWD/test-results/root/tcp" --use-tcp --daemon-tests-only -j8 + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: almalinux-8-amd64-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: almalinux-8-amd64-results + if-no-files-found: error + path: | + config.log + test-results/*/*/run.log + test-results/*/*/receipt.json + + alpine: + if: ${{ inputs.alpine }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Alpine 3.24.4 ${{ matrix.arch == 'amd64' && 'X86-64' || 'X86-32' }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + apk_arch: x86_64 + bits: 64 + image: alpine@sha256:0f49f331d9212aff11b932863be4eb01cdf962969aa86dab7d1ee21ac6f5f52b + profiles: non-asan,self-peer,linux,no-simd,no-userns + - arch: x86 + platform: linux/386 + apk_arch: x86 + bits: 32 + image: alpine@sha256:55d68c85222dd92297b0f8885354739d89f0dc17fc2f903c49dc1d9c129c3d6a + profiles: non-asan,self-peer,linux,no-simd,no-userns + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare host + run: | + sudo sysctl -w fs.protected_regular=2 + mkdir -p "$RUNNER_TEMP/rsync-alpine-${{ matrix.arch }}" + docker pull --platform "${{ matrix.platform }}" "${{ matrix.image }}" + - name: Run Alpine and collect results + env: + APK_ARCH: ${{ matrix.apk_arch }} + BITS: ${{ matrix.bits }} + PROFILES: ${{ matrix.profiles }} + run: | + docker run --rm -i --platform "${{ matrix.platform }}" \ + -e APK_ARCH -e BITS -e PROFILES -e RSYNC_TEST_TMP=/dev/shm \ + -v "$PWD:/source:ro" \ + -v "$RUNNER_TEMP/rsync-alpine-${{ matrix.arch }}:/results" \ + "${{ matrix.image }}" sh -ec ' + apk add --no-cache acl acl-dev attr attr-dev autoconf automake bash build-base diffutils gawk git \ + libidn2-dev linux-headers lz4-dev musl-locales openssl-dev py3-commonmark python3 setpriv xxhash-dev zstd-dev + exec python3 -S /source/.github/scripts/guest.py alpine --output /results + ' + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: alpine-3.24.4-${{ matrix.arch }}-bin + if-no-files-found: error + path: ${{ runner.temp }}/rsync-alpine-${{ matrix.arch }}/bin + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: alpine-3.24.4-${{ matrix.arch }}-results + if-no-files-found: error + path: | + ${{ runner.temp }}/rsync-alpine-${{ matrix.arch }}/config.log + ${{ runner.temp }}/rsync-alpine-${{ matrix.arch }}/features.json + ${{ runner.temp }}/rsync-alpine-${{ matrix.arch }}/*/*/receipt.json + ${{ runner.temp }}/rsync-alpine-${{ matrix.arch }}/*/*/run.log diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml deleted file mode 100644 index e66f5822c..000000000 --- a/.github/workflows/macos-build.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Test rsync on macOS - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/macos-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/macos-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: macos-latest - name: Test rsync on macOS - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - brew install automake openssl xxhash zstd lz4 libidn2 - pip3 install --user --break-system-packages commonmark - echo "$(brew --prefix)/bin" >>"$GITHUB_PATH" - - name: configure - run: | - BREW_PREFIX=$(brew --prefix) - OPENSSL_PREFIX=$(brew --prefix openssl) - CPPFLAGS="-I${BREW_PREFIX}/include -I${OPENSSL_PREFIX}/include" \ - LDFLAGS="-L${BREW_PREFIX}/lib -L${OPENSSL_PREFIX}/lib" \ - ./configure --with-rrsync - - name: make - run: make check-progs - - name: install - run: sudo make install - - name: info - run: rsync --version - - name: check search-only directories - run: | - ./runtests.py --rsync-bin="$PWD/rsync" \ - search-only-destination search-only-held-dirfd - - name: check - # chown-fake / devices-fake / xattrs / xattrs-hlink now RUN on macOS - # (rsyncfns.py drives xattrs via the `xattr` command), verified on a - # real macOS host, so they're no longer in the skip set. - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/macos.txt make check - - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - - name: ssl file list - run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: macos-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 000000000..4ad6a34b0 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,113 @@ +name: macOS + +on: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + macos: + strategy: + fail-fast: false + matrix: + include: + - release: '15' + arch: arm64 + runner: macos-15 + - release: '15' + arch: x86_64 + runner: macos-15-intel + - release: '26' + arch: arm64 + runner: macos-26 + - release: '26' + arch: x86_64 + runner: macos-26-intel + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + name: ${{ matrix.release }} ${{ matrix.arch == 'x86_64' && 'X86-64' || 'ARM64' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Platform + run: python3 -S .github/scripts/check-platform.py macos --release "${{ matrix.release }}" --arch "${{ matrix.arch }}" + - name: Install dependencies + run: | + brew install automake openssl xxhash zstd lz4 libidn2 + pip3 install --user --break-system-packages commonmark + echo "$(brew --prefix)/bin" >>"$GITHUB_PATH" + mkdir -p test-results + - name: Configure + run: | + BREW_PREFIX=$(brew --prefix) + OPENSSL_PREFIX=$(brew --prefix openssl) + CPPFLAGS="-I${BREW_PREFIX}/include -I${OPENSSL_PREFIX}/include" \ + LDFLAGS="-L${BREW_PREFIX}/lib -L${OPENSSL_PREFIX}/lib" \ + ./configure --with-rrsync --enable-iconv --enable-acl-support --enable-xattr-support + - name: Build + id: build + run: make check-progs + - name: Install + run: sudo make install + - name: Version + run: /usr/local/bin/rsync --version + - name: Features + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check-features.py "$PWD/rsync" zstd lz4 xxh128 xxh3 xxh64 iconv IDN ACLs xattrs > "$PWD/test-results/features.json" + - name: Directory descriptors + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + make dirfd-probe + "$PWD/dirfd-probe" "$PWD/test-results" > "$PWD/test-results/dirfd-probe.tsv" + - name: Tests (non-root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,non-asan,nonroot,self-peer,macos" "$PWD/test-results/nonroot/pipe" -j8 + - name: Tests (non-root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "non-asan,nonroot,self-peer,macos" "$PWD/test-results/nonroot/tcp" --use-tcp --daemon-tests-only -j8 + - name: Tests (root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "pipe,non-asan,root,self-peer,macos" "$PWD/test-results/root/pipe" -j8 + - name: Tests (root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: sudo python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync" "non-asan,root,self-peer,macos" "$PWD/test-results/root/tcp" --use-tcp --daemon-tests-only -j8 + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: macos-${{ matrix.release }}-${{ matrix.arch }}-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: macos-${{ matrix.release }}-${{ matrix.arch }}-results + if-no-files-found: error + path: | + config.log + test-results/features.json + test-results/dirfd-probe.tsv + test-results/*/*/receipt.json + test-results/*/*/run.log + diff --git a/.github/workflows/netbsd-build.yml b/.github/workflows/netbsd-build.yml deleted file mode 100644 index 4d2463b78..000000000 --- a/.github/workflows/netbsd-build.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Test rsync on NetBSD - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/netbsd-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/netbsd-build.yml' - schedule: - - cron: '42 8 * * 1' - -jobs: - test: - runs-on: ubuntu-latest - name: Test rsync on NetBSD - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Test in NetBSD VM - id: test - uses: vmactions/netbsd-vm@v1 - with: - usesh: true - prepare: | - PATH=/usr/sbin:$PATH pkg_add autoconf automake python312 - ln -sf /usr/pkg/bin/python3.12 /usr/pkg/bin/python3 - run: | - uname -a - ./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make - ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: netbsd-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/openbsd-build.yml b/.github/workflows/openbsd-build.yml deleted file mode 100644 index 6218b9a24..000000000 --- a/.github/workflows/openbsd-build.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Test rsync on OpenBSD - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/openbsd-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/openbsd-build.yml' - schedule: - - cron: '42 8 * * 1' - -jobs: - test: - runs-on: ubuntu-latest - name: Test rsync on OpenBSD - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Test in OpenBSD VM - id: test - uses: vmactions/openbsd-vm@v1 - with: - usesh: true - prepare: | - pkg_add -I bash autoconf%2.71 automake%1.16 - run: | - uname -a - export AUTOCONF_VERSION=2.71 - export AUTOMAKE_VERSION=1.16 - ./configure --with-rrsync --disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make - ./rsync --version - # The flipper (symlink-race) tests are excluded on OpenBSD, as on the - # fleet's OpenBSD box: this kernel has a connect()-under-rename-load - # lost-wakeup and an FFS rename-storm corruption that hang them to - # the 300s timeout for non-rsync reasons (see - # dev-notes/openbsd-connect-lost-wakeup-report.txt); the protections - # they exercise are verified on the Linux/BSD boxes. - export RSYNC_EXCLUDE=acl-symlink-race,sender-readlink-atfd,sender-remove-source-secure - make check - # The --use-tcp daemon tests run at -j2 here (vs -j8 elsewhere): this - # job runs inside a nested VM, and at -j8 the many concurrent loopback - # daemons occasionally lose a connection-handshake timing race under - # that resource pressure, hanging one test to the 300s timeout. It is - # an environment artifact, not an rsync bug (the handshake is - # deadlock-free and unreproducible elsewhere, even pinned to 1 CPU at - # -j8); -j2 keeps the VM from over-subscribing. The pipe `make check` - # above stays at the default parallelism. - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 2 - ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: openbsd-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/other.yml b/.github/workflows/other.yml new file mode 100644 index 000000000..5f266ef20 --- /dev/null +++ b/.github/workflows/other.yml @@ -0,0 +1,142 @@ +name: Other + +on: + workflow_call: + inputs: + cygwin: + required: true + type: boolean + solaris: + required: true + type: boolean + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + inputs: + description: Job selections + value: ${{ toJSON(inputs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + cygwin: + if: ${{ inputs.cygwin }} + runs-on: windows-2022 + timeout-minutes: 60 + name: Cygwin + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install Cygwin + run: .github/scripts/install-cygwin.ps1 + - name: Install CommonMark + run: bash -c 'python3 -mpip install --user commonmark' + - name: Platform + run: bash -c 'python3 -S .github/scripts/check-platform.py cygwin --arch x86_64' + - name: Configure + run: bash -c './configure --with-rrsync' + - name: Build + id: build + run: bash -c 'make check-progs' + - name: Install + run: bash -c 'make install' + - name: Version + run: bash -c '/usr/local/bin/rsync --version' + - name: Tests (non-root pipe) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: bash -c 'python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync.exe" pipe,non-asan,nonroot,self-peer,cygwin "$PWD/test-results/nonroot/pipe" -j8' + - name: Tests (non-root TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: bash -c 'python3 -S testsuite/tools/check.py "$PWD" "$PWD/rsync.exe" non-asan,nonroot,self-peer,cygwin "$PWD/test-results/nonroot/tcp" --use-tcp --daemon-tests-only -j8' + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: cygwin-bin + if-no-files-found: error + path: | + rsync.exe + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: cygwin-results + if-no-files-found: error + path: | + test-results/*/*/run.log + test-results/*/*/receipt.json + + solaris: + if: ${{ inputs.solaris }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: Solaris + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Prepare VM host + timeout-minutes: 10 + run: | + set -- qemu-utils qemu-system-x86 ovmf + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" || { + sudo apt-get update -q + sudo apt-get install -y -q -o Acquire::Retries=3 --no-install-recommends "$@" + } + - name: Run VM and collect results + uses: vmactions/solaris-vm@87d3c436511cef802cd1637f86f73b1a97715c8c # v1.4.1 + with: + cache-after-prepare: true + prepare: | + pkg install bash automake gnu-m4 pkg://solaris/runtime/python-35 autoconf gcc git + run: python3 -S .github/scripts/guest.py solaris + - name: Verify guest results + run: python3 -S .github/scripts/guest.py check + - name: Upload binaries + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: solaris-bin + if-no-files-found: error + path: | + rsync + rsync-ssl + rsync.1 + rsync-ssl.1 + rsyncd.conf.5 + rrsync.1 + rrsync + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: solaris-results + if-no-files-found: error + path: | + config.log + test-results/status + test-results/*/*/receipt.json + test-results/*/*/run.log + diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml deleted file mode 100644 index 6aafaa12e..000000000 --- a/.github/workflows/scan-build.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: rsync scan-build (clang analyzer) - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/scan-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/scan-build.yml' - workflow_dispatch: - -jobs: - # GATING run: pinned clang-18 on a pinned runner so the checker set -- and - # thus the expected zero -- is deterministic. The tree is kept clean for - # clang-18, so --status-bugs (non-zero exit on any report) fails the build - # when a new finding appears. Pin both the analyzer (clang-18/clang-tools-18) - # and the runner (ubuntu-24.04, whose apt repos carry those packages). - gate-clang18: - runs-on: ubuntu-24.04 - name: scan-build gate (clang-18, pinned) - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y clang-18 clang-tools-18 acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev libpopt-dev openssl - - name: configure (under scan-build) - # Run configure under scan-build so its analyzer compiler-wrapper is baked - # into the Makefile's $(CC); --disable-md2man avoids the doc toolchain. - run: scan-build-18 ./configure --with-rrsync --disable-md2man - - name: scan-build (gating) - # --status-bugs makes scan-build exit non-zero if it finds ANY report. - # pipefail + 'exit $status' propagate that through the tee so the job goes - # red while still printing the summary; the report uploads for triage. - run: | - set -o pipefail - status=0 - scan-build-18 --status-bugs -o "$PWD/scan-report" make check-progs -j"$(nproc)" 2>&1 | tee scan-build.out || status=$? - echo '## scan-build gate (clang-18)' >>"$GITHUB_STEP_SUMMARY" - grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true - exit $status - - name: upload report - if: always() - uses: actions/upload-artifact@v4 - with: - name: scan-build-report-clang18 - path: scan-report - if-no-files-found: ignore - - # INFORMATIONAL run: whatever clang ubuntu-latest currently ships. Newer - # clang releases enable extra, FP-heavy checkers (e.g. unix.Chroot - # "no chdir after chroot", alpha.unix.Stream) that the gate deliberately - # avoids, so this is NOT a gate (no --status-bugs). It surfaces what the - # newest analyzer sees -- useful for spotting genuine new findings before a - # gate bump -- without blocking merges. continue-on-error keeps a noisy or - # broken run from affecting the workflow's required status. - informational-latest: - runs-on: ubuntu-latest - name: scan-build (latest clang, informational) - continue-on-error: true - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y clang clang-tools acl libacl1-dev attr libattr1-dev liblz4-dev libidn2-dev libzstd-dev libxxhash-dev libpopt-dev openssl - - name: configure (under scan-build) - run: scan-build ./configure --with-rrsync --disable-md2man - - name: scan-build (informational) - run: | - scan-build -o "$PWD/scan-report" make check-progs -j"$(nproc)" 2>&1 | tee scan-build.out - echo '## scan-build informational (latest clang)' >>"$GITHUB_STEP_SUMMARY" - grep -E 'scan-build: .* bugs? found|scan-build: No bugs found' scan-build.out >>"$GITHUB_STEP_SUMMARY" || true - - name: upload report - if: always() - uses: actions/upload-artifact@v4 - with: - name: scan-build-report-latest - path: scan-report - if-no-files-found: ignore diff --git a/.github/workflows/solaris-build.yml b/.github/workflows/solaris-build.yml deleted file mode 100644 index 1b328e4a1..000000000 --- a/.github/workflows/solaris-build.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Test rsync on Solaris - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/solaris-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/solaris-build.yml' - schedule: - - cron: '42 8 * * 1' - -jobs: - test: - runs-on: ubuntu-latest - name: Test rsync on Solaris - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Test in Solaris VM - id: test - uses: vmactions/solaris-vm@v1 - with: - usesh: true - prepare: | - pkg install bash automake gnu-m4 pkg://solaris/runtime/python-35 autoconf gcc git - run: | - uname -a - ./configure --with-rrsync -disable-zstd --disable-md2man --disable-xxhash --disable-lz4 --disable-idn - make - ./rsync --version - make check - ./runtests.py --rsync-bin=`pwd`/rsync --use-tcp -j 8 - ./rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: solaris-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/ubuntu-22.04-build.yml b/.github/workflows/ubuntu-22.04-build.yml deleted file mode 100644 index ce05cf1a2..000000000 --- a/.github/workflows/ubuntu-22.04-build.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Test rsync on Ubuntu 22.04 - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Older-LTS coverage to help with backporting security fixes. ubuntu-22.04 -# is currently the oldest GitHub Actions runner image (20.04 was retired -# in April 2025). - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-22.04-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-22.04-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: ubuntu-22.04 - name: Test rsync on Ubuntu 22.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --with-rrsync - - name: make - run: make - - name: install - run: sudo make install - - name: info - run: rsync --version - - name: check - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check - - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto30.txt make check30 - - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29 - - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd; the default - # 'make check' above uses the secure stdio-pipe transport. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - - name: ssl file list - run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: ubuntu-22.04-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/ubuntu-build.yml b/.github/workflows/ubuntu-build.yml deleted file mode 100644 index 3adab91e6..000000000 --- a/.github/workflows/ubuntu-build.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Test rsync on Ubuntu - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-build.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-build.yml' - schedule: - - cron: '42 8 * * *' - -jobs: - test: - runs-on: ubuntu-latest - name: Test rsync on Ubuntu - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --with-rrsync - - name: make - run: make - - name: install - run: sudo make install - - name: info - run: rsync --version - - name: check - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check - - name: check30 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto30.txt make check30 - - name: check29 - run: sudo RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt make check29 - - name: check (TCP daemon transport) - # Second run with daemon tests over a real loopback rsyncd. The default - # 'make check' above uses the secure stdio-pipe transport (no listening - # sockets); this run exercises the real TCP accept/auth path. Skip-set - # is env-dependent here (chroot-acl), so leave RSYNC_EXPECT_SKIPPED unset. - run: sudo ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 - - name: check (non-root, targeted) - # Every run above is root (sudo), so privilege-sensitive tests never hit - # their non-root path. Run those here as the unprivileged 'runner' user - # (NO sudo). Explicit test names make runtests.py full_run False, so - # RSYNC_EXPECT_SKIPPED is bypassed -- no per-platform skip list needed. - # daemon-namecvt-empty-response -- REQUIRES non-root (skips as root by - # design); the only test with no other CI coverage (Benjamin #2). - # ownership-depth -- non-root takes the group-only remap path. - # daemon -- non-root takes the default-config path. - # CONVENTION: a new test that requires/meaningfully exercises a non-root - # path must be added to the list below (kept in sync with the fleet - # harness's nonroot_tests). - run: | - sudo rm -rf testtmp # prior root steps left it root-owned - ./runtests.py --rsync-bin="$PWD/rsync" \ - daemon-namecvt-empty-response ownership-depth daemon - - name: ssl file list - run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - - name: save artifact - uses: actions/upload-artifact@v4 - with: - retention-days: 45 - name: ubuntu-bin - path: | - rsync - rsync-ssl - rsync.1 - rsync-ssl.1 - rsyncd.conf.5 - rrsync.1 - rrsync diff --git a/.github/workflows/ubuntu-version-mix.yml b/.github/workflows/ubuntu-version-mix.yml deleted file mode 100644 index d16dd09dd..000000000 --- a/.github/workflows/ubuntu-version-mix.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Test rsync version mixing on Ubuntu - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -# Runs the CURRENT test suite with two different rsync binaries: the freshly -# built ./rsync as the client/driver, and a committed OLD static binary -# (old_versions/rsync_) as the daemon / remote-shell peer. This exercises -# real version mixing over the wire -- more convincing than --protocol forcing, -# which only makes the current binary speak an old protocol. -# -# Direction is fixed: the current binary always drives (only it understands the -# new test scripts); the old binary is only ever the server/daemon side. The -# reverse (old client driving new scripts) is not possible -- but one test, -# reverse-daemon-delta, swaps the roles internally (current build as the daemon, -# old binary as the client) to cover the backward-compat direction: a current -# daemon serving the installed base of old clients. -# -# The per-version manifest testsuite/expect/rsync_.expect lists exactly -# which tests run and each one's expected outcome (pass/skip/fail/xfail), so an -# old peer's known feature gaps are recorded rather than treated as breakage. -# -# All peers run in a SINGLE job (looped, not a matrix) so the PR shows one check -# line rather than one per version. Each peer/transport is a foldable ::group:: -# in the log, and a failure annotates which peer/transport broke. - -on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-version-mix.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/ubuntu-version-mix.yml' - schedule: - - cron: '52 8 * * 1' - -jobs: - version-mix: - runs-on: ubuntu-latest - name: rsync version-mix - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --with-rrsync - - name: make - # check-progs builds rsync AND the test helper programs (tls, trimslash, - # t_unsafe, ...) that runtests.py requires; plain `make` does not. - run: make check-progs - - name: info - run: ./rsync --version | head -1 - - name: version mixing (all peers, pipe + TCP transports) - run: | - rc=0 - for peer in old_versions/rsync_*; do - chmod +x "$peer" - name=$(basename "$peer") - expect="testsuite/expect/$name.expect" - for transport in pipe tcp; do - tcp=() - [ "$transport" = tcp ] && tcp=(--use-tcp) - echo "::group::$name ($transport): $("$peer" --version | head -1)" - if ! ./runtests.py --rsync-bin="$PWD/rsync" --rsync-bin2="$PWD/$peer" \ - --expect-result "$expect" "${tcp[@]}" -j 8; then - echo "::error::version-mix failed: $name ($transport)" - rc=1 - fi - echo "::endgroup::" - done - done - exit $rc diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml index 33be5f102..1e82705c5 100644 --- a/.github/workflows/valgrind.yml +++ b/.github/workflows/valgrind.yml @@ -1,100 +1,79 @@ -name: Valgrind memcheck - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: true +name: Valgrind on: - push: - branches: [ master ] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/valgrind.yml' - pull_request: - types: [opened, synchronize, reopened] - paths-ignore: - - '.github/workflows/*.yml' - - '!.github/workflows/valgrind.yml' - schedule: - - cron: '17 4 * * *' - workflow_dispatch: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh jobs: - memcheck: - runs-on: ubuntu-latest - timeout-minutes: 120 + test: + runs-on: ubuntu-24.04 + timeout-minutes: 60 strategy: fail-fast: false matrix: - privilege: [ user, root ] - transport: [ pipe, tcp ] - name: memcheck (${{ matrix.privilege }}, ${{ matrix.transport }}) + include: + - name: Root pipe + privilege: root + transport: pipe + profiles: pipe,non-asan,root,self-peer,linux,valgrind,no-userns + - name: Root TCP + privilege: root + transport: tcp + profiles: non-asan,root,self-peer,linux,valgrind,no-userns + - name: Non-root pipe + privilege: non-root + transport: pipe + profiles: pipe,non-asan,nonroot,self-peer,linux,valgrind,no-userns + - name: Non-root TCP + privilege: non-root + transport: tcp + profiles: non-asan,nonroot,self-peer,linux,valgrind,no-userns + name: ${{ matrix.name }} + env: + VALGRIND_SCRATCH: /tmp/rsync-valgrind-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.privilege }}-${{ matrix.transport }} steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: prep - run: | - sudo apt-get update - sudo apt-get install -y valgrind acl libacl1-dev attr libattr1-dev \ - liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl - echo "/usr/local/bin" >>"$GITHUB_PATH" - - name: configure - run: ./configure --with-rrsync --enable-debug - - name: make - run: make check-progs # builds rsync + the test helper programs runtests.py needs - - name: info - run: ./rsync --version - - # Run the whole suite under valgrind. We gate on memory *errors* (uninit - # reads, invalid read/write, bad frees, uninit syscall params), not leaks: - # rsync deliberately leaves file-list/socket/option memory unfreed at exit - # (short-lived process; the OS reclaims), so --leak-check=no avoids a sea of - # by-design "definitely lost" reports. Functional pass/fail is covered by - # the other workflows, so the suite is allowed to finish regardless of - # per-test results; the scan step below is the gate. --error-exitcode=0 - # keeps valgrind from perturbing test exit codes; the bundled - # testsuite/valgrind.supp silences known-benign reports. - - name: run testsuite under valgrind - run: | - SUDO= - [ "${{ matrix.privilege }}" = root ] && SUDO="sudo -E" - TCP= - [ "${{ matrix.transport }}" = tcp ] && TCP="--use-tcp" - $SUDO ./runtests.py --valgrind \ - --valgrind-opts="--leak-check=no --error-exitcode=0" \ - $TCP -j8 --preserve-scratch || true - - - name: scan for unsuppressed valgrind errors - run: | - sudo chown -R "$USER" testtmp 2>/dev/null || true - mapfile -t logs < <(find testtmp -name 'valgrind.*.log' 2>/dev/null) - if [ "${#logs[@]}" -eq 0 ]; then - echo "::error::no valgrind logs were produced -- the suite did not run" - exit 1 - fi - echo "scanned ${#logs[@]} valgrind log(s)" - bad=() - for f in "${logs[@]}"; do - grep -qE 'ERROR SUMMARY: [1-9][0-9]* errors' "$f" && bad+=("$f") - done - if [ "${#bad[@]}" -ne 0 ]; then - echo "::error::valgrind reported unsuppressed errors in ${#bad[@]} run(s)" - for f in "${bad[@]}"; do - echo "===== $f =====" - sed 's/==[0-9]*== //' "$f" | grep -A18 \ - -E 'depends on uninitialised|points to uninitialised|Invalid (read|write|free)|lost in loss record|Mismatched free' \ - | head -60 - done - exit 1 - fi - echo "valgrind clean: no unsuppressed errors" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y valgrind acl libacl1-dev attr libattr1-dev \ + liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl + echo "/usr/local/bin" >>"$GITHUB_PATH" + - name: Configure + run: ./configure --with-rrsync --enable-debug + - name: Build + id: build + run: make check-progs + - name: Version + run: ./rsync --version - - name: upload valgrind logs on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }} - path: testtmp/**/valgrind.*.log - if-no-files-found: ignore - retention-days: 7 + # Gate memory errors rather than intentional process-exit leaks + - name: Tests + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S .github/scripts/diagnostics.py valgrind --privilege "${{ matrix.privilege }}" --transport "${{ matrix.transport }}" --profiles "${{ matrix.profiles }}" + - name: Check memory errors + if: always() + run: python3 -S .github/scripts/diagnostics.py valgrind-results --privilege "${{ matrix.privilege }}" --transport "${{ matrix.transport }}" + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: valgrind-logs-${{ matrix.privilege }}-${{ matrix.transport }} + path: | + test-results/valgrind-logs/*.log + test-results/*.json + if-no-files-found: error + retention-days: 45 diff --git a/.github/workflows/versions.yml b/.github/workflows/versions.yml new file mode 100644 index 000000000..212621caa --- /dev/null +++ b/.github/workflows/versions.yml @@ -0,0 +1,59 @@ +name: Versions + +on: + workflow_call: + outputs: + jobs: + description: Job results + value: ${{ toJSON(jobs) }} + +permissions: + contents: read + +defaults: + run: + shell: sh + +jobs: + mix: + name: ${{ matrix.name }} + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - name: Primary + role: primary + - name: Secondary + role: secondary + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install acl libacl1-dev attr libattr1-dev liblz4-dev libzstd-dev libxxhash-dev libidn2-dev python3-cmarkgfm openssl + echo "/usr/local/bin" >>"$GITHUB_PATH" + - name: Configure + run: ./configure --with-rrsync + - name: Build + id: build + run: make check-progs + - name: Version + run: ./rsync --version + - name: Validate profiles + run: python3 -S testsuite/runtests.py profile --rsync-bin="$PWD/rsync" + - name: Tests (non-root pipe and TCP) + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 -S .github/scripts/versions.py --role "${{ matrix.role }}" + - name: Upload results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + retention-days: 45 + name: version-mix-${{ matrix.role }}-receipts + if-no-files-found: error + path: test-results/version-mix diff --git a/.gitignore b/.gitignore index 7a44021da..d4ae7a5f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.[oa] *~ +__pycache__/ dummy ID Makefile @@ -40,12 +41,14 @@ __pycache__ /testdir /tests-dont-exist /testtmp +/test-results/ /tls /testrun /trimslash /t_unsafe /wildtest /getfsdev +/dirfd-probe /simdtest /t_acl /t_chmod_secure @@ -61,11 +64,6 @@ __pycache__ /doc/rsync.pdf /doc/rsync.ps /support/savetransfer -/testsuite/chown-fake.test -/testsuite/devices-fake.test -/testsuite/xattrs-hlink.test -/testsuite/fleettest.json -/fleettest-logs /patches /patches.gen /build diff --git a/Makefile.in b/Makefile.in index 3a5d5098b..fd1436960 100644 --- a/Makefile.in +++ b/Makefile.in @@ -62,8 +62,10 @@ CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ t_rename_secure$(EXEEXT) t_symlink_secure$(EXEEXT) t_secure_relpath$(EXEEXT) t_acl$(EXEEXT) t_hashtable_overflow$(EXEEXT) t_iwildmatch$(EXEEXT) t_clean_fname$(EXEEXT) t_safe_arg$(EXEEXT) wildtest$(EXEEXT) simdtest$(EXEEXT) -CHECK_SYMLINKS = testsuite/chown-fake_test.py testsuite/devices-fake_test.py \ - testsuite/xattrs-hlink_test.py testsuite/exclude-lsh_test.py +CHECK_SYMLINKS = testsuite/tests/metadata/chown-fake.py \ + testsuite/tests/metadata/devices-fake.py \ + testsuite/tests/metadata/xattrs-hard-links.py \ + testsuite/tests/transfer/exclude-remote-shell.py # Objects for CHECK_PROGS to clean CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_rename_secure.o t_symlink_secure.o t_secure_relpath.o t_acl.o t_hashtable_overflow.o t_iwildmatch.o t_clean_fname.o t_safe_arg.o trimslash.o wildtest.o @@ -71,8 +73,7 @@ CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_se CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o syscall-no-o-directory.o \ authenticate-no-o-cloexec.o rsync-no-o-cloexec.o clientserver-no-o-directory.o \ flist-no-o-directory.o generator-no-o-directory.o sender-no-o-directory.o \ - lib/acl-no-o-directory.o -CHECK_COMPILE_OBJS=syscall-no-at-fdcwd.o access-no-inet6.o + lib/acl-no-o-directory.o access-no-inet6.o # note that the -I. is needed to handle config.h when using VPATH .c.o: @@ -292,6 +293,9 @@ T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util1.o util2.o t_stub.o lib t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS) +dirfd-probe$(EXEEXT): $(srcdir)/testsuite/tools/dirfd-probe.c config.h + $(CC) -I. -I$(srcdir) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) -o $@ $(srcdir)/testsuite/tools/dirfd-probe.c + # Unit test for lib/acl.c: compares our fd/at ACL ops against the system libacl # (linked via $(LIBS), which carries -lacl). lib/acl.o references no rsync # globals, so this links with no stubs. Self-skips (exit 77) when built @@ -390,7 +394,7 @@ rrsync.1: support/rrsync.1.md md-convert Makefile .PHONY: clean clean: cleantests rm -f *~ $(OBJS) $(CHECK_PROGS) $(CHECK_OBJS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) @MAKE_RRSYNC@ \ - git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \ + dirfd-probe$(EXEEXT) git-version.h rounding rounding.h *.old rsync*.1 rsync*.5 @MAKE_RRSYNC_1@ \ *.html daemon-parm.h help-*.h default-*.h proto.h proto.h-tstamp rm -f *.gcno *.gcda lib/*.gcno lib/*.gcda zlib/*.gcno zlib/*.gcda popt/*.gcno popt/*.gcda rm -rf coverage coverage-tcp coverage-all coverage-fallback @@ -475,22 +479,22 @@ COVERAGE_EXCLUDE = -e '(^|/)zlib/' -e '(^|/)popt/' \ -e '(^|/)lib/(md5|snprintf)\.c$$' # Build everything the test suite needs (rsync + helper programs + symlinks) -# WITHOUT running it. Used by CI jobs that invoke runtests.py directly with -# custom options (e.g. the version-mix workflow's --rsync-bin2/--expect-result). +# WITHOUT running it. Used by CI jobs that invoke testsuite/runtests.py +# directly with options such as --rsync-bin2 and --profiles. .PHONY: check-progs check-progs: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) .PHONY: check check: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) .PHONY: check29 check29: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=29 .PHONY: check30 check30: all $(CHECK_PROGS) $(CHECK_COMPILE_OBJS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(CHECK_J) --protocol=30 # Whole-suite gcov coverage report (HTML, with branch + decision coverage). # Requires a build configured with --enable-coverage and the `gcovr` tool @@ -512,13 +516,13 @@ coverage: all $(CHECK_PROGS) $(CHECK_SYMLINKS) @# world-writable so any uid can create the sibling .gcda, and set a @# default ACL of o::rw so the .gcda are world-mergeable regardless of @# the creator's umask (every test process resets umask to 022 via - @# rsyncfns.py, so a Makefile-level `umask 0` would not survive). + @# harness/rsync.py, so a Makefile-level `umask 0` would not survive). @find . -name '*.gcno' -printf '%h\n' 2>/dev/null | sort -u | \ while read d; do \ chmod a+rwx "$$d"; \ setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \ done - @rc=0; "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \ + @rc=0; "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $(COVERAGE_RUNFLAGS) || rc=$$?; \ rm -rf $(COVERAGE_DIR) && mkdir -p $(COVERAGE_DIR); \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ --gcov-ignore-parse-errors=negative_hits.warn_once_per_file \ @@ -556,9 +560,9 @@ coverage-all: all $(CHECK_PROGS) $(CHECK_SYMLINKS) setfacl -m 'd:u::rwx,d:g::rwx,d:o::rwx' "$$d" 2>/dev/null || true; \ done @rc=0; \ - for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp'; do \ + for cfg in '' '--protocol=30' '--protocol=29' '--use-tcp --daemon-tests-only'; do \ echo "===== coverage-all: runtests.py $$cfg ====="; \ - "$(srcdir)/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \ + "$(srcdir)/testsuite/runtests.py" --rsync-bin="`pwd`/rsync$(EXEEXT)" -j $(COVERAGE_J) $$cfg || rc=$$?; \ done; \ rm -rf coverage-all && mkdir -p coverage-all; \ gcovr --root $(srcdir) $(COVERAGE_EXCLUDE) --decisions --print-summary \ @@ -589,17 +593,17 @@ simdtest$(EXEEXT): simd-checksum-x86_64.cpp $(HEADERS) touch $@; \ fi -testsuite/chown-fake_test.py: - ln -s chown_test.py $(srcdir)/testsuite/chown-fake_test.py +testsuite/tests/metadata/chown-fake.py: + ln -s chown.py $(srcdir)/testsuite/tests/metadata/chown-fake.py -testsuite/devices-fake_test.py: - ln -s devices_test.py $(srcdir)/testsuite/devices-fake_test.py +testsuite/tests/metadata/devices-fake.py: + ln -s devices.py $(srcdir)/testsuite/tests/metadata/devices-fake.py -testsuite/xattrs-hlink_test.py: - ln -s xattrs_test.py $(srcdir)/testsuite/xattrs-hlink_test.py +testsuite/tests/metadata/xattrs-hard-links.py: + ln -s xattrs.py $(srcdir)/testsuite/tests/metadata/xattrs-hard-links.py -testsuite/exclude-lsh_test.py: - ln -s exclude_test.py $(srcdir)/testsuite/exclude-lsh_test.py +testsuite/tests/transfer/exclude-remote-shell.py: + ln -s exclude.py $(srcdir)/testsuite/tests/transfer/exclude-remote-shell.py # This does *not* depend on building or installing: you can use it to # check a version installed from a binary or some other source tree, @@ -607,7 +611,7 @@ testsuite/exclude-lsh_test.py: .PHONY: installcheck installcheck: $(CHECK_PROGS) $(CHECK_SYMLINKS) - "$(srcdir)/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) + "$(srcdir)/testsuite/runtests.py" --rsync-bin="$(bindir)/rsync$(EXEEXT)" --srcdir="$(srcdir)" --tooldir="`pwd`" -j $(CHECK_J) # TODO: Add 'dist' target; need to know which files will be included diff --git a/SECURITY.md b/SECURITY.md index 842019cad..da4e75de7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -393,8 +393,8 @@ The following are documented as out of scope for this release: can still slip past confinement that is anchored at the process CWD (e.g. the sender's content open), because the descriptor is not bound to the original inode. The parent-component symlink-race tests are therefore not enforced on - Cygwin (see `RSYNC_EXPECT_SKIPPED` in `.github/workflows/cygwin-build.yml` and - the Cygwin-only xfail in `symlink-race-source_test.py`). Cygwin is a + Cygwin (see the `cygwin` test profile and the Cygwin-only xfail in + `symlink-race-source.py`). Cygwin is a development/interoperability target, not a privilege boundary host, so this is accepted for this release. diff --git a/old_versions/README.md b/old_versions/README.md index a5ce3a5b6..b0483fab6 100644 --- a/old_versions/README.md +++ b/old_versions/README.md @@ -1,87 +1,30 @@ -# Old rsync version archive - -Static rsync binaries built from historical release tags. Two uses: - -1. **Cross-version behaviour checks** — confirming whether a behaviour a user - reported on an old release is version-specific or option-driven. -2. **The version-mixing test suite** — `runtests.py --rsync-bin2=...` runs the - current code against one of these as the daemon / remote-shell peer; CI - (`.github/workflows/ubuntu-version-mix.yml`) does this for every binary - here against the per-version manifests in `testsuite/expect/`. - -Binaries are **statically linked** so they run regardless of the host's -shared libraries, and named `rsync_`: - -| Binary | Version | Protocol | Notes | -|----------------|---------|----------|-----------------------------------------| -| `rsync_2.6.0` | 2.6.0 | 27 | 2004; needs autoconf regen (see below) | -| `rsync_3.0.0` | 3.0.0 | 30 | 2008 | -| `rsync_3.1.0` | 3.1.0 | 31 | 2013 | -| `rsync_3.1.3` | 3.1.3 | 31 | Ubuntu 18.04 / Debian buster era (2018) | -| `rsync_3.2.0` | 3.2.0 | 31 | 2020 (zstd/lz4/xxhash negotiation added)| -| `rsync_3.2.7` | 3.2.7 | 31 | 2022 | -| `rsync_3.3.0` | 3.3.0 | 31 | 2024 | -| `rsync_3.4.0` | 3.4.0 | 32 | 2025 | -| `rsync_3.4.1` | 3.4.1 | 32 | 2025 | - -These are every `x.y.0` release from 2.6.0 (2004) onward plus a few point -releases. 2.6.0 is the practical floor: older tags need progressively more -porting to build on a current toolchain. - -All built `--disable-openssl` and with `_FORTIFY_SOURCE` disabled (see below); -xxhash/zstd/lz4 are compiled in where the version supports them. - -## Adding a version - -```bash -./build_static.sh 3.2.7 # uses git tag v3.2.7 -./build_static.sh 3.0.9 v3.0.9 # explicit tag if naming differs +These Linux AMD64 static binaries support cross-version regression checks. They retain historical defects and must only run in isolated test environments. Peer identities live in testsuite/profiles; applicability lives in test metadata. + +Binary | Version | Protocol +--- | --- | --- +rsync_2.6.0 | 2.6.0 | 27 +rsync_3.0.0 | 3.0.0 | 30 +rsync_3.1.0 | 3.1.0 | 31 +rsync_3.1.3 | 3.1.3 | 31 +rsync_3.2.0 | 3.2.0 | 31 +rsync_3.2.7 | 3.2.7 | 31 +rsync_3.3.0 | 3.3.0 | 31 +rsync_3.4.0 | 3.4.0 | 32 +rsync_3.4.1 | 3.4.1 | 32 +rsync_3.4.2 | 3.4.2 | 32 +rsync_3.4.3 | 3.4.3 | 32 +rsync_3.4.4 | 3.4.4 | 32 +rsync_3.5.0 | 3.5.0 | 32 +rsync_3.5.1 | 3.5.1 | 33 +rsync_3.5.2 | 3.5.2 | 33 + +The archive starts at 2.6.0. The manifest records each binary's SHA-256, source commit, protocol, build-script hash, compiler, target and libc. The runner checks archived bytes and release-tag identity before executing them. + +The build script takes a release version: +```sh +old_versions/build_static.sh 3.2.7 ``` -The script checks out the tag into a throwaway `git worktree`, applies the -minimal patches needed to compile old sources on a modern toolchain, links -statically, verifies the result is static and reports the requested version, -then installs `rsync_` here and removes the worktree. - -Override the source repo with `RSYNC_REPO=/path/to/rsync ./build_static.sh ...` -(defaults to `../rsync.4`). - -## Why the patches? - -Modern GCC (>= 14, C23 default) and glibc reject things old rsync relied on. -`build_static.sh` handles these, each guarded so it's a no-op when not needed: - -1. **K&R `lseek64()` redeclaration** in `syscall.c` clashes with glibc's real - prototype — removed. -2. **`gettimeofday()`** — glibc only has the 2-arg form; configure misdetects - the 1-arg form, so `HAVE_GETTIMEOFDAY_TZ` is forced on in `config.h`. -3. **C23 `()` == `(void)`** breaks K&R prototypes called with arguments - (`qsort` comparator, `pool->bomb`, etc.) — built with `-std=gnu11`. -4. Assorted modern `-Werror` promotions (incompatible pointer types, implicit - declarations) downgraded to warnings; bundled zlib/popt used to keep the - static link self-contained. - -5. **OpenSSL (3.2+)** is disabled with `--disable-openssl`: linking - `libcrypto.a` statically drags in jitterentropy (`jent_*`) and zlib's - `uncompress` (OpenSSL's COMP module), which don't resolve here. OpenSSL only - provided optional MD4/MD5, which rsync implements natively, so checksum - behaviour is unaffected. - -6. **`_FORTIFY_SOURCE` disabled** (`-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0`): - modern Ubuntu defaults it to `=3`, whose stricter object-size checks turn - latent (historically benign) over-reads in OLD rsync into hard - `*** buffer overflow detected ***` aborts when the binary runs as a - server/daemon — which made e.g. 3.1.3 and 3.2.7 unusable as peers. Disabling - it makes the archival binaries behave as the released versions did. - -7. **Pre-3.0 tags (e.g. 2.6.0)** ship `configure.in`, not a generated - `configure`. The script runs `autoheader`/`autoconf` to generate it, after - neutralizing the `AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...))` fallbacks for - `inet_ntop`/`inet_pton`/`getaddrinfo`/`getnameinfo` — modern autoconf emits - broken shell for those never-taken branches (the funcs exist in glibc). It - also generates `proto.h` (no make rule in that era) and stubs the vendored - `lib/addrinfo.h` the tag dropped (modern glibc supplies `struct addrinfo`). - All guarded so they no-op on 3.x. +The script builds a temporary archive of the release tag and checks the version, ELF architecture, static linkage, local transfer and stdio-daemon transfer before replacing the binary. Set RSYNC_REPO for another source checkout or RSYNC_OUTPUT to qualify a candidate separately. -Newer versions may need fewer or different tweaks; if a build fails, the -script prints the first compiler errors from its log. +Build adjustments cover old Autoconf inputs, prototypes and libc declarations. OpenSSL, IDN where supported and _FORTIFY_SOURCE are disabled. Updating a binary also requires updating its manifest entry and running both roles over pipe and TCP. diff --git a/old_versions/binaries.json b/old_versions/binaries.json new file mode 100644 index 000000000..19b05271e --- /dev/null +++ b/old_versions/binaries.json @@ -0,0 +1,137 @@ +{ + "rsync_2.6.0": { + "source": "c1456d83a72215822732424304b1563a0a2c9d8f", + "protocol": 27, + "sha256": "2c5e454c4e32146a39dd6a4942752805a5862367938bac6310c089104c0c71a8", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.0.0": { + "source": "4cb6197b21d9860736c8e6d81b8eb8cac53beb5a", + "protocol": 30, + "sha256": "6ee0734b8cfe095ae45c32fea4b9b6f52e04983c43ce7f7f82feb984fa923839", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.1.0": { + "source": "bc58313bf7f42f0993f7279299e8137e0f14f01c", + "protocol": 31, + "sha256": "7f90bee892bc9b6b1a5a026ec9356284a33225709c8303bfe8e1b2afa4a66383", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.1.3": { + "source": "d73762eea3f15f2c56bb3fa9394ad1883c25c949", + "protocol": 31, + "sha256": "d305a31a93cd37a73d516c74a4656fa3d369aa28dececaf03f74c123c69a5fb4", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.2.0": { + "source": "c225330aaf9d6032a79317bda1bf669e90ac7212", + "protocol": 31, + "sha256": "3d32b82acc8e81dcf2432a7800779df1abd3e0beb266f7fb51d68a2127bb772f", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.2.7": { + "source": "6b5ae825db985e9d1c98576651b50c8e490ddb97", + "protocol": 31, + "sha256": "6273c0cd9573b2713ea449d13942e7b94db7f03e62f84c39fadc35fe97c4fd65", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.3.0": { + "source": "6c8ca91c731b7bf2b081694bda85b7dadc2b7aff", + "protocol": 31, + "sha256": "1b6bb785cc887a3fddaabedb5f6568f63bc331d3ff3d23a1c62040acddd0ec89", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.4.0": { + "source": "e3ee0e7319eb4250ddb76b716510fd20441da329", + "protocol": 32, + "sha256": "123965453fbcefdadb795b0f6a6a9f02fed4140ed8d2e847db230f2450dd9093", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.4.1": { + "source": "3305a7a063ab0167cab5bf7029da53abaa9fdb6e", + "protocol": 32, + "sha256": "4e6caaa566dcc15a9b902650520213fdaee6a45e9e1af5ad01a0640be2780ef8", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.4.2": { + "source": "de3cc03b03be153092f266aa1117b4706947ed07", + "protocol": 32, + "sha256": "19028fc541c2afab51f65b3f6fe8acdce5550e46de13cbb411d0ab423dd40585", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.4.3": { + "source": "2c7777aaa62abeb1bca192da345e6e8c685e872a", + "protocol": 32, + "sha256": "7c30cc997b14c3c2b3e12060ded7664adcf45f6d42ef2f80ccd49ea425ea0408", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.4.4": { + "source": "f26f747b8017b321d3776becc69f330dd889fa21", + "protocol": 32, + "sha256": "e4819607178c6d10ef57880901ee9eec2d605bc5af9fb23eaf1442e20fcd91c7", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.5.0": { + "source": "471e17dc0d68233db84db11be82c9f62f4661214", + "protocol": 32, + "sha256": "c4e6e97b71e2759906693f2b3938a056ee2a2b8ab1ac9137baf42923d89da92c", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.5.1": { + "source": "04355d27b7386d7de0e6bd5e79c556223210f700", + "protocol": 33, + "sha256": "1f2d75dff7303439b2967c3b539631f838d21db47efd75cfc6d4f685c5243a13", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + }, + "rsync_3.5.2": { + "source": "41bbd895668767e155b2eb6da64f37afa9cb8724", + "protocol": 33, + "sha256": "84b431e7e94eb67b03f20d95bad66257681c2ebe78a2f79893db2a3cb9b6bf27", + "recipe": "c26b0d536613ad8a3a185e4d9274fc6b27374c54fe47396f0fe500c7ed69f873", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "target": "x86_64-linux-gnu", + "libc": "ldd (Debian GLIBC 2.41-12+deb13u4) 2.41" + } +} diff --git a/old_versions/build_static.sh b/old_versions/build_static.sh index 7cabcc0cf..a3f1af9ef 100755 --- a/old_versions/build_static.sh +++ b/old_versions/build_static.sh @@ -1,66 +1,54 @@ #!/bin/bash -# Build a static rsync binary from a historical git tag, for cross-version -# behaviour testing. Produces ./rsync_ in this directory. -# -# Usage: ./build_static.sh [git-tag] -# Example: ./build_static.sh 3.1.3 # uses tag v3.1.3 -# ./build_static.sh 3.2.7 v3.2.7 -# -# Old rsync releases don't compile cleanly on a modern toolchain (GCC >= 14 -# defaults to C23, where an empty () prototype means (void); glibc dropped the -# 1-arg gettimeofday; lseek64 K&R redeclarations clash). This script applies -# the minimal, best-effort workarounds and links statically so the result is -# self-contained and reproducible regardless of the host's shared libraries. -# -# Each workaround is guarded so it's a no-op on versions that don't need it. +# Build a tagged release for the Linux AMD64 peer suite +# Usage: build_static.sh VERSION [TAG] +# RSYNC_OUTPUT selects a separate output file for qualification set -euo pipefail VERSION="${1:?usage: build_static.sh [git-tag]}" TAG="${2:-v$VERSION}" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-z]+[0-9]+)?$ ]] || { echo "Invalid version: $VERSION" >&2; exit 2; } +if [ "$(uname -sm)" != 'Linux x86_64' ]; then + echo 'The archived peer build requires Linux AMD64' >&2 + exit 2 +fi ARCHIVE_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO="${RSYNC_REPO:-/home/tridge/project/rsync/rsync.4}" # any rsync worktree -WORKTREE="$(mktemp -d /tmp/rsync-build-XXXXXX)" -OUT="$ARCHIVE_DIR/rsync_$VERSION" +REPO="${RSYNC_REPO:-$(dirname "$ARCHIVE_DIR")}" +SOURCE=$(git -c "safe.directory=$REPO" -C "$REPO" rev-parse --verify "refs/tags/$TAG^{commit}") +OUT="${RSYNC_OUTPUT:-$ARCHIVE_DIR/rsync_$VERSION}" +if [[ -L "$OUT" || ( -e "$OUT" && ! -f "$OUT" ) ]]; then + echo "Output must be a regular file: $OUT" >&2 + exit 2 +fi +pending= +BUILD_DIR="$(mktemp -d /tmp/rsync-build-XXXXXX)" -# C standard restores K&R () semantics; permissive flags downgrade the pile of -# modern -Werror promotions (incompatible pointers, implicit decls) to warnings. -# _FORTIFY_SOURCE is forced OFF: modern Ubuntu defaults it to =3, whose stricter -# object-size checks turn latent (historically benign) over-reads in OLD rsync -# into hard "*** buffer overflow detected ***" aborts when the binary acts as a -# server/daemon. Disabling it makes these archival binaries behave the way the -# released versions did, which is the whole point of the archive. +# Preserve old declarations and unchecked release-era behaviour CFLAGS_OLD="-I. -I./zlib -O2 -g -std=gnu11 -fcommon -DHAVE_CONFIG_H -Wno-error \ -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ -Wno-incompatible-pointer-types -Wno-implicit-function-declaration -Wno-int-conversion" cleanup() { - cd "$REPO" - git worktree remove --force "$WORKTREE" 2>/dev/null || true - git worktree prune 2>/dev/null || true + if [ -n "$pending" ]; then rm -f -- "$pending"; fi + case "$BUILD_DIR" in + /tmp/rsync-build-*) + [ "$(realpath "$BUILD_DIR")" = "$BUILD_DIR" ] && rm -rf -- "$BUILD_DIR" ;; + *) echo "Unexpected build directory: $BUILD_DIR" >&2 ;; + esac } trap cleanup EXIT -echo ">>> checking out $TAG into $WORKTREE" -# prefer an exact tag to avoid ambiguity with similarly-named branches -REF="$TAG" -if git -C "$REPO" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then - REF="refs/tags/$TAG" -fi -git -C "$REPO" worktree add --detach "$WORKTREE" "$REF" -cd "$WORKTREE" +echo "Building $TAG at $SOURCE" +git -c "safe.directory=$REPO" -C "$REPO" archive "$SOURCE" | tar -x -C "$BUILD_DIR" +cd "$BUILD_DIR" -# --- workaround 1: K&R lseek64 redeclaration clashes with glibc's prototype --- +# Modern headers already declare lseek64 if grep -q 'off64_t lseek64();' syscall.c 2>/dev/null; then echo ">>> patching syscall.c lseek64 redeclaration" perl -0pi -e 's/#ifdef HAVE_LSEEK64\n#if !SIZEOF_OFF64_T\n\tOFF_T lseek64\(\);\n#else\n\toff64_t lseek64\(\);\n#endif\n\treturn lseek64/#ifdef HAVE_LSEEK64\n\treturn lseek64/' syscall.c fi -# --- workaround 0: pre-3.0 tags ship configure.in, not a generated configure. -# Generate it. Modern autoconf emits broken shell for their -# AC_CHECK_FUNCS(fn,,AC_LIBOBJ(lib/...)) fallbacks -- but those branches are -# dead on a modern host (glibc has inet_ntop/inet_pton/getaddrinfo/getnameinfo), -# so neutralize the AC_LIBOBJ replacements before regenerating. +# Regenerate the old Autoconf inputs without obsolete libc replacements OLD_TREE=0 if [ ! -f ./configure ] && { [ -f configure.in ] || [ -f configure.ac ]; }; then OLD_TREE=1 @@ -72,27 +60,27 @@ if [ ! -f ./configure ] && { [ -f configure.in ] || [ -f configure.ac ]; }; then fi CONF_ARGS=(--disable-md2man --with-included-zlib=yes --with-included-popt=yes) -# OpenSSL (3.2+) only adds optional MD4/MD5 that rsync already implements, but -# linking libcrypto.a statically drags in jitterentropy + zlib's uncompress, -# which aren't resolvable here. Drop it when the flag exists. -if ./configure --help 2>/dev/null | grep -q -- '--disable-openssl'; then - echo ">>> disabling openssl for self-contained static link" +# Use the bundled hashes for the static peer build +help=$(./configure --help) +if [[ "$help" == *--disable-openssl* ]]; then + echo "Using bundled hashes" CONF_ARGS+=(--disable-openssl) fi +if [[ "$help" == *--disable-idn* ]]; then + CONF_ARGS+=(--disable-idn) +fi echo ">>> configure (bundled zlib + popt, static-friendly)" ./configure "${CONF_ARGS[@]}" \ - >"$WORKTREE/conf.log" 2>&1 || { tail -20 "$WORKTREE/conf.log"; exit 1; } + >"$BUILD_DIR/conf.log" 2>&1 || { tail -20 "$BUILD_DIR/conf.log"; exit 1; } -# --- workaround 2: modern glibc only has the 2-arg gettimeofday --------------- +# Modern glibc requires the second gettimeofday argument if grep -q '/\* #undef HAVE_GETTIMEOFDAY_TZ \*/' config.h; then echo ">>> forcing HAVE_GETTIMEOFDAY_TZ (configure misdetects it)" sed -i 's|/\* #undef HAVE_GETTIMEOFDAY_TZ \*/|#define HAVE_GETTIMEOFDAY_TZ 1|' config.h fi -# --- workaround 4 (old trees only): generate proto.h if the tree has no make -# rule for it, and stub a vendored lib/addrinfo.h that the git tag dropped -# (modern glibc supplies struct addrinfo / sockaddr_storage, so empty is right). +# Old tags need generated prototypes and current libc address declarations if [ "$OLD_TREE" = 1 ]; then if [ ! -f proto.h ] && [ -f mkproto.awk ]; then echo ">>> generating proto.h" @@ -105,24 +93,36 @@ if [ "$OLD_TREE" = 1 ]; then fi echo ">>> building (static)" -make -j"$(nproc)" CFLAGS="$CFLAGS_OLD" LDFLAGS="-static" \ - >"$WORKTREE/make.log" 2>&1 || { grep -E 'error:|\*\*\*' "$WORKTREE/make.log" | head; exit 1; } +make -j8 CFLAGS="$CFLAGS_OLD" LDFLAGS="-static" \ + >"$BUILD_DIR/make.log" 2>&1 || { grep -E 'error:|\*\*\*' "$BUILD_DIR/make.log" | head; exit 1; } -# verify it's actually static before we keep it -if ldd ./rsync 2>&1 | grep -qv 'not a dynamic executable'; then - echo "ERROR: binary is not statically linked:" >&2 - ldd ./rsync >&2 +elf=$(readelf -h -l -d ./rsync) +[[ "$elf" == *'Advanced Micro Devices X86-64'* ]] +if [[ "$elf" == *INTERP* || "$elf" == *NEEDED* ]]; then + echo "Binary is not statically linked" >&2 exit 1 fi - -GOT="$(./rsync --version | head -1 | awk '{print $3}')" +banner=$(timeout -k 2 10 ./rsync --version) +GOT=$(printf '%s\n' "$banner" | awk 'NR == 1 {print $3}') if [ "$GOT" != "$VERSION" ]; then - echo "ERROR: built version '$GOT' != requested '$VERSION'" >&2 + echo "Built version '$GOT' differs from '$VERSION'" >&2 exit 1 fi -cp ./rsync "$OUT" -strip "$OUT" -echo ">>> installed $OUT" -"$OUT" --version | head -1 -file "$OUT" +mkdir smoke-source smoke-local smoke-daemon +printf 'peer transfer\n' > smoke-source/file +timeout -k 2 30 ./rsync -r smoke-source/ smoke-local/ +cmp smoke-source/file smoke-local/file +printf 'use chroot = no\nuid = %s\ngid = %s\nlog file = %s\n[module]\npath = %s\nread only = no\n' \ + "$(id -u)" "$(id -g)" "$BUILD_DIR/smoke.log" "$BUILD_DIR/smoke-daemon" > smoke.conf +RSYNC_CONNECT_PROG="'$BUILD_DIR/rsync' --daemon --config='$BUILD_DIR/smoke.conf'" \ + timeout -k 2 30 ./rsync -r smoke-source/ rsync://localhost/module/ +cmp smoke-source/file smoke-daemon/file + +pending=$(mktemp "$OUT.XXXXXX") +install -m 755 ./rsync "$pending" +strip "$pending" +mv -T -- "$pending" "$OUT" +pending= +printf '%s\n' "$banner" +sha256sum "$OUT" diff --git a/old_versions/rsync_2.6.0 b/old_versions/rsync_2.6.0 index face7a023..a2c49cf47 100755 Binary files a/old_versions/rsync_2.6.0 and b/old_versions/rsync_2.6.0 differ diff --git a/old_versions/rsync_3.0.0 b/old_versions/rsync_3.0.0 index c844826ca..2fa6b8de3 100755 Binary files a/old_versions/rsync_3.0.0 and b/old_versions/rsync_3.0.0 differ diff --git a/old_versions/rsync_3.1.0 b/old_versions/rsync_3.1.0 index ebb876077..ed3170034 100755 Binary files a/old_versions/rsync_3.1.0 and b/old_versions/rsync_3.1.0 differ diff --git a/old_versions/rsync_3.1.3 b/old_versions/rsync_3.1.3 index 3cd1ac4ae..8f6ffd532 100755 Binary files a/old_versions/rsync_3.1.3 and b/old_versions/rsync_3.1.3 differ diff --git a/old_versions/rsync_3.2.0 b/old_versions/rsync_3.2.0 index a28c4058a..913ad585f 100755 Binary files a/old_versions/rsync_3.2.0 and b/old_versions/rsync_3.2.0 differ diff --git a/old_versions/rsync_3.2.7 b/old_versions/rsync_3.2.7 index 7ca168206..329155b62 100755 Binary files a/old_versions/rsync_3.2.7 and b/old_versions/rsync_3.2.7 differ diff --git a/old_versions/rsync_3.3.0 b/old_versions/rsync_3.3.0 index 1169447b2..ae6d05dfb 100755 Binary files a/old_versions/rsync_3.3.0 and b/old_versions/rsync_3.3.0 differ diff --git a/old_versions/rsync_3.4.0 b/old_versions/rsync_3.4.0 index 7432dbd4c..45e59d50f 100755 Binary files a/old_versions/rsync_3.4.0 and b/old_versions/rsync_3.4.0 differ diff --git a/old_versions/rsync_3.4.1 b/old_versions/rsync_3.4.1 index 4c66adddd..e2c5fc14b 100755 Binary files a/old_versions/rsync_3.4.1 and b/old_versions/rsync_3.4.1 differ diff --git a/old_versions/rsync_3.4.2 b/old_versions/rsync_3.4.2 new file mode 100755 index 000000000..52829cc4f Binary files /dev/null and b/old_versions/rsync_3.4.2 differ diff --git a/old_versions/rsync_3.4.3 b/old_versions/rsync_3.4.3 new file mode 100755 index 000000000..fbe1222eb Binary files /dev/null and b/old_versions/rsync_3.4.3 differ diff --git a/old_versions/rsync_3.4.4 b/old_versions/rsync_3.4.4 new file mode 100755 index 000000000..d32be50f3 Binary files /dev/null and b/old_versions/rsync_3.4.4 differ diff --git a/old_versions/rsync_3.5.0 b/old_versions/rsync_3.5.0 new file mode 100755 index 000000000..f88ede367 Binary files /dev/null and b/old_versions/rsync_3.5.0 differ diff --git a/old_versions/rsync_3.5.1 b/old_versions/rsync_3.5.1 new file mode 100755 index 000000000..301651588 Binary files /dev/null and b/old_versions/rsync_3.5.1 differ diff --git a/old_versions/rsync_3.5.2 b/old_versions/rsync_3.5.2 new file mode 100755 index 000000000..c0a943a1e Binary files /dev/null and b/old_versions/rsync_3.5.2 differ diff --git a/packaging/release.py b/packaging/release.py index 186148c96..0c5c9e6ee 100755 --- a/packaging/release.py +++ b/packaging/release.py @@ -22,6 +22,7 @@ from datetime import datetime sys.path = ['packaging'] + sys.path +from source_archive import GEN_FILES, make_archive from pkglib import ( warn, die, cmd_run, cmd_chk, cmd_txt, cmd_txt_chk, cmd_pipe, @@ -63,20 +64,6 @@ def default_release_dir(): FTP_REMOTE_PATH = '/home/ftp/pub/rsync' 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 during the release build). -GEN_FILES = [ - 'configure.sh', - 'aclocal.m4', - 'config.h.in', - 'rsync.1', 'rsync.1.html', - 'rsync-ssl.1', 'rsync-ssl.1.html', - 'rsyncd.conf.5', 'rsyncd.conf.5.html', - 'rrsync.1', 'rrsync.1.html', -] - # ---------- Phase registry ---------- PHASES = [ @@ -618,11 +605,7 @@ def step_7_tarball(args): rsync_ver_dir = os.path.join(WORK_DIR, rsync_ver) shutil.rmtree(a_dir) os.rename(b_dir, rsync_ver_dir) - cmd_chk(f"git archive --format=tar --prefix={rsync_ver}/ {v_ver} | " - f"tar -C {WORK_DIR} -xf -") - cmd_chk(f"support/git-set-file-times --quiet --prefix={rsync_ver_dir}/") - cmd_chk(['fakeroot', 'tar', '-C', WORK_DIR, '-czf', srctar_file, - '--exclude=.github', rsync_ver]) + make_archive(v_ver, rsync_ver_dir, srctar_file) # Leave staging in place for the FTP refresh. print(f"\nCreated:\n {srctar_file}\n {diff_file}") diff --git a/packaging/source_archive.py b/packaging/source_archive.py new file mode 100644 index 000000000..c521a908f --- /dev/null +++ b/packaging/source_archive.py @@ -0,0 +1,37 @@ +from pathlib import Path +import shutil +import subprocess +import tempfile + + +GEN_FILES = [ + 'configure.sh', 'aclocal.m4', 'config.h.in', + 'rsync.1', 'rsync.1.html', 'rsync-ssl.1', 'rsync-ssl.1.html', + 'rsyncd.conf.5', 'rsyncd.conf.5.html', 'rrsync.1', 'rrsync.1.html', +] + + +def make_archive(revision, stage, output): + stage, output = Path(stage).resolve(), Path(output).resolve() + if not stage.name.startswith('rsync-') or not stage.is_dir(): + raise ValueError('Invalid release staging directory') + if {path.name for path in stage.iterdir()} != set(GEN_FILES): + raise ValueError('Release staging must contain exactly the generated release files') + for name in GEN_FILES: + if not (stage / name).is_file() or (stage / name).is_symlink(): + raise ValueError(f'Missing generated release file: {name}') + head = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip() + selected = subprocess.check_output(['git', 'rev-parse', revision + '^{commit}'], text=True).strip() + if head != selected: + raise ValueError('Archive timestamps require the selected revision to be checked out') + with tempfile.TemporaryDirectory(prefix='rsync-archive-') as directory: + tracked = Path(directory) / 'tracked.tar' + subprocess.run(['git', 'archive', '--format=tar', '--prefix=' + stage.name + '/', + '--output=' + str(tracked), selected], check=True, timeout=120) + subprocess.run(['tar', '-C', str(stage.parent), '-xf', str(tracked)], check=True, timeout=120) + subprocess.run(['support/git-set-file-times', '--quiet', '--prefix=' + str(stage) + '/'], + check=True, timeout=120) + archive = Path(directory) / 'source.tar.gz' + subprocess.run(['fakeroot', 'tar', '-C', str(stage.parent), '-czf', str(archive), + '--exclude=.github', stage.name], check=True, timeout=120) + shutil.copyfile(archive, output) diff --git a/runtests.py b/runtests.py deleted file mode 100755 index 4f62c12f6..000000000 --- a/runtests.py +++ /dev/null @@ -1,1024 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright (C) 2001, 2002 by Martin Pool -# Copyright (C) 2003-2022 Wayne Davison -# Copyright (C) 2026 Andrew Tridgell -# -# Rewrite of runtests.sh in Python (runtests.sh is now deprecated). -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version -# 2 as published by the Free Software Foundation. - -"""rsync test runner. - -Invokes test scripts from testsuite/ and reports results. -Can be called by 'make check' or directly. - -Usage: - ./runtests.py [options] [TEST ...] - -Each TEST is a test name (e.g. 'delete') or glob pattern (e.g. 'xattr*'). -If no tests are specified, all tests are run. -""" - -import argparse -import concurrent.futures -import fnmatch -import glob -import math -import os -import signal -import subprocess -import sys -import threading -import time - -# Share the test exit-code enum with the test helpers. exitcodes.py lives in -# testsuite/ (next to this script); it has no import-time side effects. -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testsuite')) -from exitcodes import Exit - - -def _race_seconds(text): - """argparse type for --race-timeout: a finite, strictly positive number. - - A race test loops `while monotonic() < deadline`, so a budget of 0 (or a - negative, or a NaN, which fails every comparison) runs the body ZERO times - and the test reports PASS without ever exercising its oracle -- a silently - disarmed security test, which is worse than a slow one. Infinity would run - until the unrelated per-test timeout. The old max(RACE_TIMEOUT, 10.0) floor - used to make this unreachable; validating here restores that guarantee.""" - try: - secs = float(text) - except ValueError: - raise argparse.ArgumentTypeError(f'not a number: {text!r}') - if not math.isfinite(secs) or secs <= 0: - raise argparse.ArgumentTypeError( - f'must be a finite positive number of seconds, got {text!r}; ' - 'a zero/negative/NaN budget would make every race test pass ' - 'without running its race') - return secs - - -def parse_args(): - p = argparse.ArgumentParser(description='Run rsync test suite') - p.add_argument('tests', nargs='*', metavar='TEST', - help='Test names or patterns to run (default: all)') - p.add_argument('--exclude', default=None, metavar='LIST', - help='Comma-separated test names/globs to skip entirely: ' - 'they are not run and not reported as skipped. Useful ' - 'for tests that cannot work in a given build/CI ' - 'environment (e.g. a restricted buildd chroot). ' - 'Falls back to the RSYNC_EXCLUDE environment variable.') - p.add_argument('-j', '--parallel', type=int, default=1, metavar='N', - help='Run up to N tests in parallel (default: 1)') - p.add_argument('--valgrind', action='store_true', - help='Run rsync under valgrind (logs to per-process files)') - p.add_argument('--valgrind-opts', default='', metavar='OPTS', - help='Extra valgrind options (e.g. "--leak-check=full")') - p.add_argument('--preserve-scratch', action='store_true', - help='Keep scratch directories after tests complete') - p.add_argument('--log-level', type=int, default=1, metavar='N', - help='Verbosity level 1-10 (default: 1)') - p.add_argument('--always-log', action='store_true', - help='Show test logs even for passing tests') - p.add_argument('--stop-on-fail', action='store_true', - help='Stop after first test failure') - p.add_argument('--timing', action='store_true', - help='After the run, report each test\'s wall-clock time, ' - 'slowest first. With -j N the report also shows how ' - 'much of the run the slowest test alone accounts for.') - p.add_argument('--timeout', type=int, default=300, metavar='SECS', - help='Per-test timeout in seconds (default: 300)') - p.add_argument('--race-timeout', type=_race_seconds, default=None, metavar='SECS', - help='Budget (seconds) a TOCTOU symlink-race test may spend ' - 'trying to win its race before concluding. Overrides ' - 'every such test\'s own default (5-15s, the suite\'s ' - 'slowest tests: a race test always spends its whole ' - 'budget). Lowering it speeds the suite up but weakens ' - 'the oracle. Unset: each test keeps its default.') - p.add_argument('--rsync-bin', default=None, metavar='PATH', - help='Path to rsync binary (default: ./rsync)') - p.add_argument('--rsync-bin2', default=None, metavar='PATH', - help='Path to a second ("peer") rsync binary used for the ' - 'daemon side and remote-shell --rsync-path. Lets the ' - 'suite mix two rsync versions over the wire. Default: ' - 'same as --rsync-bin (no version mixing).') - p.add_argument('--tooldir', default=None, metavar='DIR', - help='Tool/build directory (default: cwd)') - p.add_argument('--srcdir', default=None, metavar='DIR', - help='Source directory (default: script directory)') - p.add_argument('--protocol', type=int, default=None, metavar='VER', - help='Force protocol version (adds --protocol=VER to rsync)') - p.add_argument('--expect-skipped', default=None, metavar='LIST', - help='Comma-separated list of expected-skipped tests. An ' - '@FILE entry reads a skip list (one test per line, ' - '"#" comments); relative paths resolve against srcdir ' - 'and several may be composed, e.g. ' - '@testsuite/skiplist/linux.txt,@testsuite/skiplist/proto29.txt. ' - 'A -NAME entry removes a name the rest of the spec ' - 'added, for a host that can really run a test its ' - 'platform list expects to skip.') - p.add_argument('--expect-result', default=None, metavar='FILE', - help='Path to an expected-outcome manifest (one ' - '" " per line). When ' - 'set, ONLY the tests listed in FILE are run, and each ' - "test's actual outcome is compared against its " - 'expected one; any mismatch (including an unexpected ' - 'pass) fails the run. Used for version-mixing CI.') - p.add_argument('--daemon-tests-only', action='store_true', - help='Run only the tests that can reach the daemon ' - 'transport. Intended for a --use-tcp pass that follows ' - 'a full default-transport run: the tests this drops ' - 'never call start_test_daemon(), so they cannot observe ' - '--use-tcp and would just repeat themselves. Disables ' - 'the expected-skip oracle (it describes a full run).') - p.add_argument('--use-tcp', action='store_true', - help='Run daemon tests against a real rsyncd bound to ' - '127.0.0.1 (non-default). The default is the secure ' - 'stdio-pipe transport, which opens no listening ' - 'socket; --use-tcp exposes a loopback port for the ' - 'duration of each daemon test.') - return p.parse_args() - - -def find_setfacl_nodef(scratchbase): - """Determine the setfacl command to remove default ACLs.""" - for cmd in [ - ['setacl', '-k', 'u::7,g::5,o:5', scratchbase], - ['setfacl', '-k', scratchbase], - ['setfacl', '-s', 'u::7,g::5,o:5', scratchbase], - ]: - try: - subprocess.run(cmd, capture_output=True, timeout=5) - return cmd[:2] if cmd[0] == 'setacl' else cmd[:2] - except (FileNotFoundError, subprocess.TimeoutExpired): - continue - try: - r = subprocess.run(['setfacl', '--help'], capture_output=True, text=True, timeout=5) - if '-k,' in r.stdout or '-k,' in r.stderr: - return ['setfacl', '-k'] - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - return None - - -def get_tls_args(config_h): - """Determine TLS_ARGS from config.h.""" - args = '' - try: - with open(config_h) as f: - text = f.read() - if '#define HAVE_LUTIMES 1' in text: - args += ' -l' - if '#undef CHOWN_MODIFIES_SYMLINK' in text: - args += ' -L' - except FileNotFoundError: - pass - return args.strip() - - -def read_shconfig(path): - """Read shell config variables from shconfig.""" - env = {} - try: - with open(path) as f: - for line in f: - line = line.strip() - if line.startswith('#') or line.startswith('export') or not line: - continue - if '=' in line: - k, _, v = line.partition('=') - env[k.strip()] = v.strip().strip('"') - except FileNotFoundError: - pass - return env - - -def get_testuser(): - """Determine the current test user.""" - for cmd in ['/usr/bin/whoami', '/usr/ucb/whoami', '/bin/whoami']: - if os.path.isfile(cmd): - try: - return subprocess.check_output([cmd], text=True).strip() - except subprocess.CalledProcessError: - pass - try: - return subprocess.check_output(['id', '-un'], text=True).strip() - except (FileNotFoundError, subprocess.CalledProcessError): - return os.environ.get('LOGNAME', os.environ.get('USER', 'UNKNOWN')) - - -def _move_aside(path): - """Rename an un-removable directory to a unique sibling so its name is free. - - A rename-storm symlink-race test can corrupt a directory on some filesystems - (OpenBSD FFS soft-updates can leave an "empty" dir that still reports - ENOTEMPTY/EPERM and only fsck clears). `rm -rf` then can't remove it, but - renaming the top dir aside succeeds even with a corrupted descendant, freeing - the original name for a clean scratchdir.""" - n = 0 - while os.path.exists(f"{path}.corrupt.{os.getpid()}.{n}"): - n += 1 - try: - os.rename(path, f"{path}.corrupt.{os.getpid()}.{n}") - except OSError: - pass - - -def prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef): - """Prepare a scratch directory for a test.""" - if os.path.isdir(scratchdir): - subprocess.run(['chmod', '-R', 'u+rwX', scratchdir], capture_output=True) - subprocess.run(['rm', '-rf', scratchdir], capture_output=True) - if os.path.isdir(scratchdir): - _move_aside(scratchdir) # rm -rf left corrupted debris; don't inherit it - os.makedirs(scratchdir, exist_ok=True) - if setfacl_nodef: - subprocess.run(setfacl_nodef + [scratchdir], capture_output=True) - try: - os.chmod(scratchdir, os.stat(scratchdir).st_mode & ~0o2000) # clear setgid - except OSError: - pass - src_link = os.path.join(scratchdir, 'src') - if not os.path.exists(src_link): - if os.path.isabs(srcdir): - os.symlink(srcdir, src_link) - else: - os.symlink(os.path.join(tooldir, srcdir), src_link) - - -# Python tests are identified by a positive "_test.py" suffix so that -# helper modules (e.g. rsyncfns.py) sit in testsuite/ without being mistaken -# for tests. -_PY_TEST_SUFFIX = '_test.py' - - -def _is_test_path(path): - return os.path.basename(path).endswith(_PY_TEST_SUFFIX) - - -def _testbase(path): - """Strip the test extension to get the canonical test name.""" - base = os.path.basename(path) - if base.endswith(_PY_TEST_SUFFIX): - return base[:-len(_PY_TEST_SUFFIX)] - return base - - -def collect_tests(suitedir, patterns): - """Collect test scripts (_test.py) matching the given patterns.""" - if not patterns: - candidates = glob.glob(os.path.join(suitedir, '*' + _PY_TEST_SUFFIX)) - tests = sorted(p for p in candidates if _is_test_path(p)) - else: - seen = set() - tests = [] - for pat in patterns: - # Accept either bare name ("mkpath"), explicit extension, or glob. - if pat.endswith('.py'): - pats = [pat] - else: - pats = [pat + _PY_TEST_SUFFIX] - for p in pats: - for m in sorted(glob.glob(os.path.join(suitedir, p))): - if _is_test_path(m) and m not in seen: - seen.add(m) - tests.append(m) - return tests - - -# Tokens through which a test can reach the daemon transport. --use-tcp works by -# setting RSYNC_TEST_USE_TCP, which is read in exactly one place (rsyncfns -# USE_TCP) and acted on in exactly one function (start_test_daemon): a test whose -# source mentions none of these never gets there, so it behaves identically with -# and without --use-tcp and running it a second time under TCP buys no coverage. -# -# The list is the closure of every rsyncfns helper that reaches USE_TCP, -# start_rsyncd or claim_ports, plus the helper modules that open a daemon -# connection themselves and the bare literals a test might use directly. It is -# deliberately over-broad: a false positive only costs runtime, while a false -# negative would silently drop real coverage. -_DAEMON_API = ( - 'USE_TCP', 'require_tcp', 'start_test_daemon', 'start_rsyncd', - 'claim_ports', 'claim_free_port', 'setup_chroot_inner', - 'stdio_daemon', 'rsync_proto', 'DaemonClient', - 'rsync://', '--daemon', 'rsyncd', -) - - -def select_daemon_tests(tests): - """Split `tests` into (daemon-transport tests, the rest). - - Used by --daemon-tests-only so a TCP pass need not re-run the whole suite. - A test we cannot read is kept, not dropped -- the failure mode of this - filter must always be "ran too much".""" - keep, dropped = [], [] - for path in tests: - try: - with open(path, errors='replace') as f: - text = f.read() - except OSError: - keep.append(path) - continue - (keep if any(tok in text for tok in _DAEMON_API) else dropped).append(path) - return keep, dropped - - -_VALID_OUTCOMES = ('pass', 'skip', 'fail', 'xfail') - - -def parse_expect_result(path): - """Parse an expected-outcome manifest into {testbase: outcome}. - - One " " entry per line; '#' comments and blank lines - are ignored. outcome is one of pass|skip|fail|xfail. The set of listed - tests doubles as the run set (see main()). Exits 2 on a malformed file. - """ - expect = {} - with open(path) as f: - for lineno, raw in enumerate(f, 1): - line = raw.split('#', 1)[0].strip() - if not line: - continue - fields = line.split() - if len(fields) != 2 or fields[1] not in _VALID_OUTCOMES: - sys.stderr.write( - f"{path}:{lineno}: expected ' " - f"<{'|'.join(_VALID_OUTCOMES)}>', got: {raw.rstrip()}\n" - ) - sys.exit(Exit.ERROR) - expect[fields[0]] = fields[1] - return expect - - -def expand_skip_spec(spec, srcdir, suitedir): - """Expand an RSYNC_EXPECT_SKIPPED spec into a normalised csv. - - The spec is a comma-separated list of test names, '@FILE' skip-list - references, and '-name' removals. A skip-list file holds one test name per line ('#' starts a - comment; blank lines are ignored), which is what keeps two branches from - colliding: adding a test edits one line of one file rather than a shared - 3 KB csv. Several may be composed, e.g. - RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/linux.txt,@.../proto29.txt - Relative paths resolve against srcdir (not the cwd) so out-of-tree builds - and `make installcheck` work. A '-name' entry removes a name the rest of - the spec added, for a host that can genuinely run a test its platform list - expects to skip; it is applied last, and must actually remove something. - - Entries must name a real test, and each file must be non-empty, sorted and - free of duplicates: unsorted files defeat the point (everyone appends to - the same last line), and a stale name would otherwise fail as a skip - mismatch far from its cause. Exits 2 on any of those -- nothing malformed - may quietly shrink the expected set, which would disarm the oracle. - """ - def die(msg): - sys.stderr.write(msg + '\n') - sys.exit(Exit.ERROR) - - # An entirely empty spec is the legitimate "expect no skips at all". An - # empty entry *within* a spec is not: it is what an unset shell variable - # expands to, and silently dropping it would quietly shrink the expected - # set. - if not spec.strip(): - return '' - - names = [] - drop = [] - for tok in (t.strip() for t in spec.split(',')): - if not tok: - die('RSYNC_EXPECT_SKIPPED: empty entry (an unset variable?): ' - f'{spec!r}') - if tok.startswith('-'): - # '-name' removes a name a composed list added, for a host that - # really can run a test its platform list expects to skip (e.g. a - # scratch dir on a second filesystem makes a cross-device copy - # work). Subtraction cannot be done by whoever composes the spec, - # because the name lives inside an @FILE that is only expanded - # here. Applied after every addition, so order does not matter. - drop.append((tok[1:], tok)) - continue - if not tok.startswith('@'): - names.append((tok, 'RSYNC_EXPECT_SKIPPED')) - continue - path = tok[1:] - if not os.path.isabs(path): - path = os.path.join(srcdir, path) - try: - with open(path) as f: - lines = f.readlines() - except (OSError, UnicodeDecodeError) as e: - die(f'{tok}: cannot read skip list: {e}') - prev = None - found = 0 - for lineno, raw in enumerate(lines, 1): - name = raw.split('#', 1)[0].strip() - if not name: - continue - where = f'{path}:{lineno}' - if len(name.split()) != 1: - die(f'{where}: expected one test name per line, got: {raw.rstrip()}') - if prev is not None and name <= prev: - die(f'{where}: skip lists must be sorted and duplicate-free ' - f'({name!r} follows {prev!r})') - prev = name - found += 1 - names.append((name, where)) - # A truncated or emptied list must not read as "expect no skips". - if not found: - die(f'{path}: skip list contains no test names') - - seen = {} - for name, where in names: - if name in seen: - continue - seen[name] = where - # A plain test name: no path, and no comma (which the expanded csv, - # and the summary the fleet parses back, use as the separator). - if ',' in name or '/' in name or os.sep in name or name in ('.', '..'): - die(f'{where}: not a test name: {name!r}') - if not os.path.isfile(os.path.join(suitedir, name + '_test.py')): - die(f'{where}: no such test: {name}') - for name, tok in drop: - # Every removal must remove something. A name the spec never added is - # stale (the list stopped expecting that skip, or it is misspelled), and - # a repeated removal is the same no-op written twice. Shrinking the - # expected set is precisely what must not happen quietly, so neither is - # allowed to sit in a config unnoticed. - if name not in seen: - die(f'RSYNC_EXPECT_SKIPPED: {tok!r} removes a name that nothing ' - f'added: {name}') - del seen[name] - return ','.join(sorted(seen)) - - -def read_backport_exclude(tooldir, suitedir): - """Test names from the BUILD tree's testsuite/skiplist/backport.txt. - - A stable-backport branch runs a newer suite than its own code, and this - file names the tests that base cannot run. It lives in the tree being - built, not the suite tree, so it cannot be an @FILE in the expect-skipped - spec -- those resolve against srcdir. - """ - path = os.path.join(tooldir, 'testsuite', 'skiplist', 'backport.txt') - if not os.path.isfile(path): - return set() - names = set() - with open(path) as f: - for lineno, raw in enumerate(f, 1): - name = raw.split('#', 1)[0].strip() - if not name: - continue - where = f'{path}:{lineno}' - if len(name.split()) != 1: - sys.stderr.write(f'{where}: expected one test name per line\n') - sys.exit(Exit.ERROR) - # A stale name here would silently exclude nothing, which is the - # failure this file exists to prevent. - if not os.path.isfile(os.path.join(suitedir, name + '_test.py')): - sys.stderr.write(f'{where}: no such test: {name}\n') - sys.exit(Exit.ERROR) - names.add(name) - return names - - -_TIMING_TOP = 25 - - -def print_timing_report(durations, outcomes, run_wall, parallel): - """Report per-test wall-clock, slowest first (--timing). - - With -j N the run cannot finish sooner than its slowest single test, so the - tail matters as much as the total: a 300s test pins the whole suite to 300s - no matter how many workers there are. The footer gives both bounds -- the - serial sum (what one worker would take) and that floor -- so it is obvious - whether a slow run wants more parallelism or a faster individual test.""" - if not durations: - return - ranked = sorted(durations.items(), key=lambda kv: kv[1], reverse=True) - total = sum(durations.values()) - print(f'----- slowest tests (of {len(ranked)}, wall-clock each):') - for name, secs in ranked[:_TIMING_TOP]: - print(f' {secs:7.1f}s {name:<32} {outcomes.get(name, "?")}') - print(f' serial sum {total:.0f}s over {len(ranked)} tests; ' - f'run took {run_wall:.0f}s with -j{parallel}') - slowest, slowest_secs = ranked[0] - if parallel > 1: - # The floor: even with unlimited workers the suite cannot beat its - # longest single test. - print(f' floor {slowest_secs:.0f}s ({slowest}) = ' - f'{100.0 * slowest_secs / run_wall:.0f}% of this run; ' - f'ideal at -j{parallel} is {total / parallel:.0f}s') - - -def outcome_of(result): - """Map a per-test exit code to an outcome string.""" - if result == Exit.PASS: - return 'pass' - if result == Exit.SKIP: - return 'skip' - if result == Exit.XFAIL: - return 'xfail' - return 'fail' - - -def build_rsync_cmd(rsync_bin, args, scratchbase): - """Build the RSYNC command string for tests.""" - parts = [] - if args.valgrind: - # Logs go in a world-writable+sticky subdir so that rsync children - # which drop privileges (the setpriv cap-drop in partial_nowrite, a - # daemon dropping to the module's uid) can still create their log file - # even when scratchbase itself is root-owned. - vgdir = os.path.join(scratchbase, 'valgrind-logs') - os.makedirs(vgdir, exist_ok=True) - os.chmod(vgdir, 0o1777) - vlog = os.path.join(vgdir, 'valgrind.%p.log') - vopts = f'--log-file={vlog}' - supp = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'testsuite', 'valgrind.supp') - if os.path.exists(supp): - vopts += f' --suppressions={supp}' - if args.valgrind_opts: - vopts += ' ' + args.valgrind_opts - parts.append(f'valgrind {vopts}') - parts.append(rsync_bin) - if args.protocol is not None: - parts.append(f'--protocol={args.protocol}') - return ' '.join(parts) - - -class TestResult: - """Result of a single test execution.""" - __slots__ = ('testbase', 'result', 'output', 'skipped_reason', 'duration') - - def __init__(self, testbase, result, output='', skipped_reason='', - duration=0.0): - self.testbase = testbase - self.result = result - self.output = output - self.skipped_reason = skipped_reason - self.duration = duration - - -def run_one_test(testscript, testbase, scratchdir, base_env, timeout, - srcdir, tooldir, setfacl_nodef, always_log): - """Run a single test. Returns a TestResult. - - This function is safe to call from multiple threads — it uses only - per-test state (unique scratchdir, copy of env). - """ - started = time.monotonic() - prep_scratch(scratchdir, srcdir, tooldir, setfacl_nodef) - - env = base_env.copy() - env['scratchdir'] = scratchdir - - # Dispatch by extension: shell tests via /bin/sh -e, Python tests via - # the same python3 that's running this runner. - if testscript.endswith('.py'): - cmd = [sys.executable, testscript] - else: - cmd = ['sh', '-e', testscript] - - logfile = os.path.join(scratchdir, 'test.log') - with open(logfile, 'w') as log: - # start_new_session: run the test driver as its own session/group leader - # so the daemon, clients and flipper it spawns inherit that group. A - # timeout then killpg's the whole tree (not just the driver), and the - # lock-file sweep can reap a SIGKILLed run's stranded group the same way. - proc = subprocess.Popen( - cmd, - stdout=log, stderr=subprocess.STDOUT, - env=env, cwd=env.get('TOOLDIR', '.'), - start_new_session=True, - ) - try: - result = proc.wait(timeout=timeout) - except subprocess.TimeoutExpired: - # Reap the whole session group, but only if the driver really is its - # own group leader (start_new_session took) and that group isn't ours - # -- killpg of our own group would take down the runner. - try: - pgid = os.getpgid(proc.pid) - except OSError: - pgid = -1 - if pgid == proc.pid and pgid != os.getpgrp(): - try: - os.killpg(pgid, signal.SIGKILL) - except OSError: - proc.kill() - else: - proc.kill() - proc.wait() - result = 1 - log.write(f"\nTIMEOUT: test took over {timeout} seconds\n") - - # Build output text - output_parts = [] - - show_log = always_log or (result not in (Exit.PASS, Exit.SKIP, Exit.XFAIL)) - if show_log: - output_parts.append(f'----- {testbase} log follows') - try: - with open(logfile) as f: - output_parts.append(f.read().rstrip()) - except FileNotFoundError: - pass - output_parts.append(f'----- {testbase} log ends') - rsyncd_log = os.path.join(scratchdir, 'rsyncd.log') - if os.path.isfile(rsyncd_log): - output_parts.append(f'----- {testbase} rsyncd.log follows') - with open(rsyncd_log) as f: - output_parts.append(f.read().rstrip()) - output_parts.append(f'----- {testbase} rsyncd.log ends') - - skipped_reason = '' - if result == Exit.PASS: - output_parts.append(f'PASS {testbase}') - elif result == Exit.SKIP: - whyfile = os.path.join(scratchdir, 'whyskipped') - try: - with open(whyfile) as f: - skipped_reason = f.read().strip() - except FileNotFoundError: - pass - output_parts.append(f'SKIP {testbase} ({skipped_reason})') - elif result == Exit.XFAIL: - output_parts.append(f'XFAIL {testbase}') - else: - output_parts.append(f'FAIL {testbase}') - - return TestResult(testbase, result, '\n'.join(output_parts), skipped_reason, - time.monotonic() - started) - - -# Lock for serializing output in parallel mode -_print_lock = threading.Lock() - - -def main(): - args = parse_args() - - # Also accept legacy environment variables - if args.preserve_scratch or os.environ.get('preserve_scratch') == 'yes': - args.preserve_scratch = True - if args.log_level == 1: - args.log_level = int(os.environ.get('loglevel', '1')) - if args.expect_skipped is None: - args.expect_skipped = os.environ.get('RSYNC_EXPECT_SKIPPED', 'IGNORE') - if args.exclude is None: - args.exclude = os.environ.get('RSYNC_EXCLUDE', '') - if os.environ.get('whichtests'): - args.tests = [os.environ['whichtests']] - - # Determine directories - tooldir = args.tooldir or os.environ.get('TOOLDIR') or os.getcwd() - script_path = os.path.dirname(os.path.abspath(__file__)) - srcdir = args.srcdir or script_path - if not srcdir or srcdir == '.': - srcdir = tooldir - rsync_bin = args.rsync_bin or os.environ.get('rsync_bin') or os.path.join(tooldir, 'rsync') - # Absolutize: tests run with subprocess(cwd=TOOLDIR) below, so a relative - # argv[0] would re-resolve against TOOLDIR rather than the runner's - # invocation cwd, breaking --rsync-bin=../foo/rsync forms. abspath() - # captures os.getcwd() now, which is what the operator intended. - if rsync_bin and not os.path.isabs(rsync_bin): - rsync_bin = os.path.abspath(rsync_bin) - - # Optional second ("peer") binary for the daemon / remote-shell side, so a - # run can mix two rsync versions. Defaults to rsync_bin -> no mixing. - rsync_bin2 = args.rsync_bin2 or os.environ.get('rsync_bin2') or rsync_bin - if rsync_bin2 and not os.path.isabs(rsync_bin2): - rsync_bin2 = os.path.abspath(rsync_bin2) - - suitedir = os.path.join(srcdir, 'testsuite') - # A backport tree excludes what its base cannot run. Those tests never - # run, so they must also drop out of the expected-skip set -- otherwise the - # oracle demands a skip from a test that was never started. - backport_excl = read_backport_exclude(tooldir, suitedir) - if backport_excl: - args.exclude = ','.join(x for x in (args.exclude, - ','.join(sorted(backport_excl))) if x) - if args.expect_skipped != 'IGNORE': - args.expect_skipped = expand_skip_spec(args.expect_skipped, srcdir, suitedir) - if backport_excl: - args.expect_skipped = ','.join(n for n in args.expect_skipped.split(',') - if n and n not in backport_excl) - scratchbase = os.path.join(os.environ.get('scratchbase', tooldir), 'testtmp') - os.makedirs(scratchbase, exist_ok=True) - - shconfig = read_shconfig(os.path.join(tooldir, 'shconfig')) - tls_args = get_tls_args(os.path.join(tooldir, 'config.h')) - setfacl_nodef = find_setfacl_nodef(scratchbase) - rsync_cmd = build_rsync_cmd(rsync_bin, args, scratchbase) - rsync_peer_cmd = build_rsync_cmd(rsync_bin2, args, scratchbase) - - if not os.path.isfile(rsync_bin): - sys.stderr.write(f"rsync_bin {rsync_bin} is not a file\n") - sys.exit(Exit.ERROR) - if not os.path.isfile(rsync_bin2): - sys.stderr.write(f"rsync_bin2 {rsync_bin2} is not a file\n") - sys.exit(Exit.ERROR) - if not os.path.isdir(srcdir): - sys.stderr.write(f"srcdir {srcdir} is not a directory\n") - sys.exit(Exit.ERROR) - - # Helper programs the test scripts invoke directly. Missing any of these - # would cause many tests to fail with confusing "not found" errors, so - # check up front and point the user at the make target that builds them. - required_helpers = ['tls', 'trimslash', 't_unsafe', 't_chmod_secure', - 't_secure_relpath', - 'wildtest', 'getgroups', 'getfsdev'] - missing = [h for h in required_helpers - if not os.path.isfile(os.path.join(tooldir, h))] - if missing: - sys.stderr.write( - f"runtests.py: missing test helper program(s) in {tooldir}: " - f"{', '.join(missing)}\n" - f"Build them with: make {' '.join(missing)}\n" - f"or run the full test target: make check\n" - ) - sys.exit(Exit.ERROR) - - testuser = get_testuser() - - # Print header - print('=' * 60) - print(f'{sys.argv[0]} running in {tooldir}') - print(f' rsync_bin={rsync_cmd}') - if rsync_peer_cmd != rsync_cmd: - print(f' rsync_peer={rsync_peer_cmd}') - print(f' srcdir={srcdir}') - print(f' TLS_ARGS={tls_args}') - print(f' testuser={testuser}') - print(f' os={subprocess.check_output(["uname", "-a"], text=True).strip()}') - print(f' preserve_scratch={"yes" if args.preserve_scratch else "no"}') - if args.valgrind: - print(f' valgrind=enabled (logs in valgrind-logs/valgrind.*.log)') - if args.parallel > 1: - print(f' parallel={args.parallel}') - print(f' daemon_transport={"tcp (loopback)" if args.use_tcp else "pipe (secure default)"}') - print(f' scratchbase={scratchbase}') - - # Build base environment for test scripts - path = os.environ.get('PATH', '') - if os.path.isdir('/usr/xpg4/bin'): - path = '/usr/xpg4/bin:' + path - - # Make the testsuite/ directory importable so Python tests can `import rsyncfns`. - pythonpath = suitedir - if os.environ.get('PYTHONPATH'): - pythonpath = suitedir + os.pathsep + os.environ['PYTHONPATH'] - - base_env = os.environ.copy() - base_env.update({ - 'PATH': path, - 'POSIXLY_CORRECT': '1', - 'TOOLDIR': tooldir, - 'srcdir': srcdir, - 'RSYNC': rsync_cmd, - 'RSYNC_PEER': rsync_peer_cmd, - 'TLS_ARGS': tls_args, - 'RUNSHFLAGS': '-e', - 'scratchbase': scratchbase, - 'suitedir': suitedir, - 'TESTRUN_TIMEOUT': str(args.timeout), - 'HOME': scratchbase, - 'PYTHONPATH': pythonpath, - }) - if args.use_tcp: - # Opt-in: daemon tests start a real rsyncd on a claimed loopback port. - # Default (unset) keeps the secure stdio-pipe transport. - base_env['RSYNC_TEST_USE_TCP'] = '1' - if args.race_timeout is not None: - # Only exported when the operator actually passed --race-timeout: its - # mere presence is what tells a race test to override its own default. - base_env['race_timeout'] = str(args.race_timeout) - else: - # A stale value inherited from the environment would silently override - # every test's default; the flag is the only way to set this. - base_env.pop('race_timeout', None) - for k, v in shconfig.items(): - if v: - base_env[k] = v - if setfacl_nodef: - base_env['setfacl_nodef'] = ' '.join(setfacl_nodef) - else: - base_env['setfacl_nodef'] = 'true' - if args.log_level > 8: - base_env['RUNSHFLAGS'] = '-e -x' - - # Collect tests - tests = collect_tests(suitedir, args.tests) - full_run = len(args.tests) == 0 - - # Drop excluded tests entirely (matched by basename against name/glob). - excl = [e.strip() for e in args.exclude.split(',') if e.strip()] - if excl: - before = len(tests) - tests = [t for t in tests - if not any(fnmatch.fnmatch(_testbase(t), pat) for pat in excl)] - if before != len(tests): - print(f"Excluding {before - len(tests)} test(s) matching: " - f"{', '.join(excl)}") - - # Narrow to the daemon-transport tests. The dropped count is always printed: - # a pass that silently ran a third of the suite would read in the report as - # if it had run all of it. - if args.daemon_tests_only: - tests, dropped = select_daemon_tests(tests) - print(f"Daemon-transport tests only: running {len(tests)}, skipping " - f"{len(dropped)} test(s) that cannot observe the transport") - # The expected-skip list describes a full run, so it cannot be enforced - # against a subset -- same rule as naming tests explicitly. - full_run = False - - # An expected-result manifest defines BOTH the run set (its keys) and the - # expected per-test outcome (its values). Used for version-mixing runs. - expect = parse_expect_result(args.expect_result) if args.expect_result else None - if expect is not None: - have = {_testbase(t) for t in tests} - unknown = sorted(k for k in expect if k not in have) - if unknown: - sys.stderr.write( - "runtests.py: --expect-result lists test(s) with no matching " - f"test file (ignored): {', '.join(unknown)}\n" - ) - tests = [t for t in tests if _testbase(t) in expect] - full_run = False - - def _cls(outcome): - """Equivalence class for outcome comparison: fail and xfail both just - mean 'broke', so a manifest 'fail' matches an actual fail OR xfail.""" - return 'broken' if outcome in ('fail', 'xfail') else outcome - - def mismatch(testbase, actual): - """True if actual outcome disagrees with the manifest expectation.""" - return expect is not None and _cls(expect[testbase]) != _cls(actual) - - # Record test order for consistent skipped-list output - test_order = {_testbase(t): i for i, t in enumerate(tests)} - - passed = 0 - failed = 0 - skipped = 0 - xfailed = 0 - skipped_list = [] - outcomes = {} # testbase -> actual outcome string ('pass'/'skip'/'fail'/'xfail') - durations = {} # testbase -> wall-clock seconds (for --timing) - - def process_result(tr): - """Process a TestResult and update counters. Returns True if the test - should count as a failure for --stop-on-fail purposes.""" - nonlocal passed, failed, skipped, xfailed - with _print_lock: - if tr.output: - print(tr.output) - scratchdir = os.path.join(scratchbase, tr.testbase) - oc = outcome_of(tr.result) - outcomes[tr.testbase] = oc - durations[tr.testbase] = tr.duration - if tr.result == Exit.PASS: - passed += 1 - elif tr.result == Exit.SKIP: - skipped_list.append(tr.testbase) - skipped += 1 - elif tr.result == Exit.XFAIL: - # XFAIL: an expected failure (a known, documented residual the test - # asserts against). Reported distinctly but does NOT fail the suite; - # when the underlying issue is fixed the test returns 0 instead. - xfailed += 1 - else: - failed += 1 - if tr.result in (Exit.PASS, Exit.SKIP, Exit.XFAIL) and not args.preserve_scratch \ - and os.path.isdir(scratchdir): - subprocess.run(['rm', '-rf', scratchdir], capture_output=True) - # With a manifest, only a mismatch is a "failure" (an expected fail is - # fine); without one, any non-pass/non-skip/non-xfail result is a failure. - if expect is not None: - return mismatch(tr.testbase, oc) - return tr.result not in (Exit.PASS, Exit.SKIP, Exit.XFAIL) - - run_started = time.monotonic() - - if args.parallel > 1: - # Parallel execution - with concurrent.futures.ThreadPoolExecutor(max_workers=args.parallel) as executor: - futures = {} - for testscript in tests: - testbase = _testbase(testscript) - scratchdir = os.path.join(scratchbase, testbase) - timeout = 600 if ('hardlinks' in testbase or testbase == 'variety') else args.timeout - f = executor.submit( - run_one_test, testscript, testbase, scratchdir, - base_env, timeout, srcdir, tooldir, setfacl_nodef, - args.always_log - ) - futures[f] = testbase - - for f in concurrent.futures.as_completed(futures): - tr = f.result() - is_fail = process_result(tr) - if is_fail and args.stop_on_fail: - # Cancel pending futures - for pending in futures: - pending.cancel() - break - else: - # Sequential execution - for testscript in tests: - testbase = _testbase(testscript) - scratchdir = os.path.join(scratchbase, testbase) - timeout = 600 if ('hardlinks' in testbase or testbase == 'variety') else args.timeout - tr = run_one_test( - testscript, testbase, scratchdir, - base_env, timeout, srcdir, tooldir, setfacl_nodef, - args.always_log - ) - is_fail = process_result(tr) - if is_fail and args.stop_on_fail: - break - - run_wall = time.monotonic() - run_started - - # Check valgrind logs for errors - vg_errors = 0 - if args.valgrind: - for vlog in sorted(glob.glob(os.path.join(scratchbase, 'valgrind-logs', 'valgrind.*.log'))): - try: - with open(vlog) as f: - content = f.read() - for line in content.splitlines(): - if 'ERROR SUMMARY:' in line and 'ERROR SUMMARY: 0 errors' not in line: - vg_errors += 1 - print(f'----- valgrind errors in {os.path.basename(vlog)}:') - print(content) - break - except FileNotFoundError: - pass - - # Summary - print('-' * 60) - print('----- overall results:') - print(f' {passed} passed') - if failed > 0: - print(f' {failed} failed') - if xfailed > 0: - print(f' {xfailed} xfailed (expected)') - if skipped > 0: - print(f' {skipped} skipped') - if vg_errors > 0: - print(f' {vg_errors} valgrind error(s) found (see logs in {os.path.join(scratchbase, "valgrind-logs")})') - - if args.timing: - print_timing_report(durations, outcomes, run_wall, args.parallel) - - if expect is not None: - # Version-mixing mode: the run is judged purely on whether each test's - # actual outcome matched its manifest expectation. An expected 'fail' - # is fine; an UNEXPECTED pass (xpass) or any other divergence is not. - mismatches = [] - for tb in sorted(expect, key=lambda x: test_order.get(x, 1 << 30)): - actual = outcomes.get(tb, 'notrun') - if actual == 'notrun' or mismatch(tb, actual): - mismatches.append((tb, expect[tb], actual)) - if mismatches: - print('----- expected-result mismatches:') - for tb, want, got in mismatches: - tag = ' (xpass)' if _cls(want) == 'broken' and got == 'pass' else '' - print(f' {tb}: expected {want}, got {got}{tag}') - print('-' * 60) - exit_code = len(mismatches) + vg_errors - print(f'overall result is {exit_code}') - sys.exit(exit_code) - - skipped_str = ','.join(sorted(skipped_list, key=lambda x: test_order.get(x, 0))) - if full_run and args.expect_skipped != 'IGNORE': - print('----- skipped results:') - print(f' expected: {args.expect_skipped}') - print(f' got: {skipped_str}') - else: - skipped_str = '' - args.expect_skipped = '' - - print('-' * 60) - - exit_code = failed + vg_errors - if exit_code == 0: - # Compare the skipped set order-insensitively: which tests skipped is - # what matters, not the order runtests happened to collect them in - # (that order is just sorted filenames -- an easy thing to get subtly - # wrong when maintaining the per-platform expected lists). - got = set(s for s in skipped_str.split(',') if s) - want = set(s for s in args.expect_skipped.split(',') if s) - if got != want: - exit_code = 1 - - print(f'overall result is {exit_code}') - sys.exit(exit_code) - - -if __name__ == '__main__': - main() diff --git a/support/rrsh.sh b/support/rrsh.sh index 83025f2f5..fc598cf12 100755 --- a/support/rrsh.sh +++ b/support/rrsh.sh @@ -1,12 +1,5 @@ #!/bin/sh -# abdiff helper: a "remote shell" that emulates an sshd forced-command of -# `rrsync DIR`. rsync invokes a remote shell as: -# [ssh-opts] -# so when used as -e "sh rrsh.sh " rsync calls us as: -# sh rrsh.sh [opts] lh rsync --server ... -# We hand the server command to rrsync via SSH_ORIGINAL_COMMAND (exactly as -# sshd would) and exec the restricted wrapper, so abdiff can A/B the rrsync -# path itself. Only the pretend hosts "lh"/"localhost" are accepted. +# Emulate an sshd forced command for the version comparison's rrsync transport. RRSYNC="$1"; DIR="$2"; shift 2 while [ $# -gt 0 ]; do case "$1" in diff --git a/testsuite/00-hello_test.py b/testsuite/00-hello_test.py deleted file mode 100644 index cdc153798..000000000 --- a/testsuite/00-hello_test.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# Python rewrite of testsuite/00-hello.test. -# -# Foundational smoke test: --version / --info=help / --debug=help all -# work, plus a round-trip transfer of a directory whose name contains -# shell-special characters via the lsh.sh remote-shell stand-in. - -import os - -from rsyncfns import ( - FROMDIR, RSYNC, RSYNC_PEER, SRCDIR, TODIR, - checkit, run_rsync, test_fail, rsync_path_arg, rsh_cmd, -) - - -# Set RSYNC_RSH so rsync picks up lsh.sh for the "lh:" hosts below. -os.environ['RSYNC_RSH'] = rsh_cmd() - -# Basic help dumps must not crash. -if run_rsync('--version', check=False).returncode != 0: - test_fail('--version output failed') -if run_rsync('--info=help', check=False).returncode != 0: - test_fail('--info=help output failed') -if run_rsync('--debug=help', check=False).returncode != 0: - test_fail('--debug=help output failed') - -weird_name = "A weird)name" - -FROMDIR.mkdir(parents=True, exist_ok=True) -weird_dir = FROMDIR / weird_name -weird_dir.mkdir() - - -def append_line(line: str) -> None: - print(line) - with open(weird_dir / 'file', 'a') as f: - f.write(line + '\n') - - -def copy_weird(args: list, src_host: str, dst_host: str) -> None: - checkit( - [*args, f'--rsync-path={rsync_path_arg()}', - f'{src_host}{weird_dir}/', - f'{dst_host}{TODIR / weird_name}'], - FROMDIR, TODIR, - ) - - -append_line('test1') -checkit(['-ai', f'{FROMDIR}/', f'{TODIR}/'], FROMDIR, TODIR) - -append_line('test2') -copy_weird(['-ai'], 'lh:', '') - -append_line('test3') -copy_weird(['-ai'], '', 'lh:') - -append_line('test4') -copy_weird(['-ais'], 'lh:', '') - -append_line('test5') -copy_weird(['-ais'], '', 'lh:') - -# test6: --old-args lets two whitespace-separated names go through as a -# single "one two" remote argument to be re-split by the remote shell. -print('test6') -(FROMDIR / 'one').touch() -(FROMDIR / 'two').touch() - -saved = os.getcwd() -os.chdir(FROMDIR) -try: - run_rsync('-ai', '--old-args', f'--rsync-path={rsync_path_arg()}', - 'lh:one two', f'{TODIR}/') -finally: - os.chdir(saved) - -if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file(): - test_fail("old-args copy of 'one two' failed") - -# test7: the RSYNC_OLD_ARGS=1 env var should be equivalent to --old-args. -print('test7') -(TODIR / 'one').unlink() -(TODIR / 'two').unlink() - -env = os.environ.copy() -env['RSYNC_OLD_ARGS'] = '1' -import subprocess -from rsyncfns import rsync_argv - -os.chdir(FROMDIR) -try: - subprocess.run( - rsync_argv('-ai', f'--rsync-path={rsync_path_arg()}', - 'lh:one two', f'{TODIR}/'), - env=env, check=True, - ) -finally: - os.chdir(saved) - -# check=True only proves a zero exit; confirm the env-var path actually copied -# both files (as the explicit --old-args case above does). -if not (TODIR / 'one').is_file() or not (TODIR / 'two').is_file(): - test_fail("RSYNC_OLD_ARGS=1 copy of 'one two' failed") diff --git a/testsuite/COVERAGE.md b/testsuite/COVERAGE.md index 6f09d4527..142d14e1d 100644 --- a/testsuite/COVERAGE.md +++ b/testsuite/COVERAGE.md @@ -1,205 +1,209 @@ -# rsync option / daemon-parameter test coverage matrix - -Living checklist for the test-coverage effort that precedes the path-handling -restructure of rsync's path resolution. The restructure rewrites parent-directory -resolution for essentially every option, so the goal here is a regression net -that exercises each option **at directory depth** (≥3 levels) and, where the -option spans trees, **across directory boundaries**, asserting the *specific -property* the option controls — not just `dest == src`. - -How to read the columns: - -* **test(s)** — the `testsuite/*_test.py` that exercise the option. Tests added - by this effort are marked `*new*`. -* **depth** — Y = asserted on entries ≥3 levels deep; `~` = exercised only at/near - the tree root; `n/a` = not a path-resolution option. -* **x-dir** — Y = exercised with the relevant aux tree (temp/backup/dest/partial) - **outside** the main tree; `—` = not a cross-directory option. -* **gap** — what is still missing. - -Status legend: ✓ property asserted · `~` shallow / by an existing ported test · -✗ no coverage. - ---- - -## Command-line options - -### Recursion / structure -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -a, --archive | (all) | Y | — | ✓ ubiquitous | -| -r, --recursive | hands, delete-deep*new* | Y | — | ✓ | -| -R, --relative | relative, relative-implied*new* | Y | — | ✓ implied-dir attrs at depth | -| --no-implied-dirs | relative-implied*new* | Y | — | ✓ (proto 30+; proto 29 rejects multi-component path) | -| --inc-recursive / --no-inc-recursive | hardlinks | Y | — | `~` exercised, not isolated | -| -d, --dirs | dirs*new* | Y | — | ✓ no-recurse top layer | -| --old-dirs / --old-d | — | — | — | ✗ | -| -m, --prune-empty-dirs | prune-empty-dirs*new* | Y | — | ✓ incl. filter-emptied chains | - -### Links -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -l, --links | links*new*, symlink-ignore | Y | — | ✓ | -| -L, --copy-links | links*new* | Y | — | ✓ deref file+dir | -| -k, --copy-dirlinks | links*new* | Y | — | ✓ follow dir-symlink | -| -K, --keep-dirlinks | symlink-dirlink-basis | Y | — | ✓ #715; skips on no-RESOLVE_BENEATH / --disable-openat2 | -| -H, --hard-links | hardlinks, hardlinks-deep*new* | Y | Y | ✓ cross-directory hardlink | -| --copy-unsafe-links | unsafe-links | `~` | — | `~` | -| --safe-links | safe-links | `~` | — | `~` | -| --munge-links | (daemon-munge*new* covers the daemon param) | — | — | `~` client option not isolated; local mode is a near no-op | - -### Metadata / permissions / ownership -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -p, --perms | metadata-depth*new* | Y | — | ✓ exact modes per entry | -| -E, --executability | executability | `~` | — | `~` | -| --chmod | metadata-depth*new*, chmod-option | Y | — | ✓ | -| -A, --acls | acls, acls-depth*new* | Y | — | ✓ | -| -X, --xattrs | xattrs, xattrs-depth*new* | Y | — | ✓ | -| -t, --times | metadata-depth*new* | Y | — | ✓ | -| -U, --atimes | atimes | `~` | — | `~` (same set path as -t, covered deep) | -| --open-noatime | open-noatime | `~` | — | `~` | -| -N, --crtimes | crtimes | `~` | — | `~` (skips without crtimes support) | -| -O, --omit-dir-times | omit-times*new* | Y | — | ✓ | -| -J, --omit-link-times | omit-times*new* | Y | — | ✓ | -| -o, --owner | chown, ownership-depth*new* | Y | — | ✓ uid map root-gated | -| -g, --group | chgrp, ownership-depth*new* | Y | — | ✓ group remap non-root | -| --super / --fake-super | chown, chown-fake | `~` | — | `~` | -| --numeric-ids | — | — | — | ✗ client; daemon `numeric ids` also ✗ | -| --usermap / --groupmap | ownership-depth*new* | Y | — | ✓ groupmap non-root; usermap root-gated | -| --chown | ownership-depth*new* | Y | — | ✓ group half | -| -D / --devices / --specials | devices, devices-fake | `~` | — | `~` root/device-gated | -| --copy-devices / --write-devices | — | — | — | ✗ device-gated | -| -S, --sparse | sparse*new* | Y | — | ✓ hole preserved at depth | - -### Delta / temp / backup / dest (highest restructure risk) -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -T, --temp-dir | temp-dir*new*, chmod-temp-dir | Y | Y | ✓ cross-dir rename | -| --partial | partial*new* | Y | — | ✓ partial kept in dest file | -| --partial-dir | partial*new*, symlink-dirlink-basis | Y | Y | ✓ relative (in-tree) + absolute (outside), incl. delta resume from an absolute outside-tree partial | -| --delay-updates | delay-updates, delay-updates-deep*new* | Y | — | ✓ per-dir staging | -| --inplace | inplace*new*, alt-dest | Y | — | ✓ inode preserved | -| --append / --append-verify | append*new* | Y | — | ✓ verify split is proto 30+ | -| -b, --backup / --backup-dir / --suffix | backup, backup-deep*new* | Y | Y | ✓ | -| --compare-dest / --copy-dest / --link-dest | alt-dest, alt-dest-deep*new* | Y | Y | ✓ link=hardlink, copy=copy, compare=skip | -| -y, --fuzzy | fuzzy | `~` | — | `~` | -| -u, --update | update*new* | Y | — | ✓ keeps newer dest, updates older | -| -W, --whole-file | (used widely; --no-whole-file ubiquitous) | n/a | — | `~` | -| --mkpath | mkpath | `~` | — | `~` | -| -x, --one-file-system | — | — | — | ✗ (needs a mount boundary) | -| --preallocate / --fsync | — | — | — | ✗ | -| -B, --block-size | — | — | — | ✗ | -| --max-alloc | max-alloc-zero | — | — | ✓ zero resolves to each peer's supported ceiling; values above the limit are rejected | - -### Filtering -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -f, --filter / -F | filter-depth*new*, merge | Y | — | ✓ deep per-dir merge | -| --exclude / --include | filter-depth*new*, exclude, exclude-lsh | Y | — | ✓ | -| --exclude-from / --include-from | files-from-depth*new* | Y | — | ✓ | -| -C, --cvs-exclude | cvs-exclude*new* | Y | — | ✓ incl. deep .cvsignore | -| --files-from | files-from-depth*new* | Y | — | ✓ | -| -0, --from0 | files-from-depth*new* | Y | — | ✓ | -| --max-size / --min-size | size-filter*new* | Y | — | ✓ | -| --existing / --ignore-existing | delete-deep*new* | Y | — | ✓ | -| --ignore-missing-args / --delete-missing-args | — | — | — | ✗ | - -### Deletion -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| --delete / --del | delete, delete-deep*new* | Y | — | ✓ deep subtree | -| --delete-before/during/delay/after | delete-deep*new* | Y | — | ✓ all four agree | -| --delete-excluded | delete | `~` | — | `~` | -| --max-delete | delete-deep*new* | Y | — | ✓ caps deletions | -| --remove-source-files | delete | `~` | — | `~` | -| --force | update*new* | Y | — | ✓ replaces a non-empty dir with a file | -| --ignore-errors | — | — | — | ✗ (client; daemon `ignore errors` also ✗) | - -### Comparison / checksum / compression -| option | test(s) | depth | x-dir | notes / gap | -|---|---|---|---|---| -| -c, --checksum | compare*new* | Y | — | ✓ catches stealth change | -| -I, --ignore-times | compare*new* | Y | — | ✓ | -| --size-only | compare*new* | Y | — | ✓ | -| -@, --modify-window | compare*new* | Y | — | ✓ | -| --checksum-choice / --checksum-seed | compress-options*new* | Y | — | ✓ every advertised algo | -| -z, --compress | daemon-gzip-{up,down}load, daemon-refuse-compress | `~` | — | `~` | -| --compress-choice / --compress-level / --skip-compress | compress-options*new* | Y | — | ✓ | - -### Output / reporting (path-irrelevant — checked for output shape) -| option | test(s) | notes / gap | -|---|---|---| -| -i, --itemize-changes | output-options*new*, itemize | ✓ | -| -n, --dry-run | output-options*new* | ✓ | -| --stats | output-options*new* | ✓ | -| --out-format | output-options*new* | ✓ | -| --list-only | output-options*new* | ✓ | -| -q, --quiet | output-options*new* | ✓ | -| --progress / -P | output-options*new* | ✓ (--progress) | -| -h, --human-readable / -8, --8-bit-output | output-options*new* | ✓ smoke | -| --version / --help | output-options*new* | ✓ | -| --info / --debug / --stderr / --no-motd / --outbuf | — | ✗ | -| -M, --remote-option / --log-file / --log-file-format | — | ✗ (daemon `log file` covered) | - -### Batch / connection / misc -| option | test(s) | notes / gap | -|---|---|---| -| --write-batch / --only-write-batch / --read-batch | batch-mode | `~` | -| -e, --rsh / --rsync-path | ssh-basic, many | `~` | -| --protocol | check29 / check30 (whole suite) | ✓ | -| --address / --port | daemon tests under --use-tcp | `~` | -| --password-file | daemon-auth*new* | ✓ | -| --early-input / daemon `early exec` | — | ✗ | -| --sockopts / --blocking-io / --timeout / --contimeout | — | ✗ | -| -4/-6, --ipv4/--ipv6 | — | ✗ | -| --stop-after / --stop-at | — | ✗ | -| --bwlimit | partial*new* (used, not asserted) | `~` | -| --copy-as | — | ✗ root-gated | -| --iconv | — | ✗ | -| --nice / --no-nice | niceness | ✓ local, remote shell, local server, daemon and rrsync | -| -s/--secluded-args, --old-args, --trust-sender | (default arg-protection exercised) | `~` | - ---- - -## Daemon (rsyncd.conf) parameters - -| parameter | test(s) | notes / gap | -|---|---|---| -| path | daemon-access*new*, all daemon tests | ✓ incl. deep sub-path | -| read only | daemon-access*new*, daemon | ✓ | -| write only | daemon-access*new* | ✓ | -| list | daemon-access*new*, daemon | ✓ hidden-but-usable | -| use chroot | sender-flist-symlink-leak, daemon-chroot-acl | `~` (no=most tests; yes needs root) | -| munge symlinks | daemon-munge*new* | ✓ /rsyncd-munged/ add+strip | -| exclude / include | daemon-filter*new*, daemon | ✓ exclude | -| filter / exclude from / include from | — | ✗ (exclude covers the mechanism) | -| incoming chmod | daemon-filter*new*, chmod-option | ✓ | -| outgoing chmod | daemon-filter*new* | ✓ | -| auth users / secrets file | daemon-auth*new* | ✓ accept/reject/unauth | -| strict modes | daemon-auth*new* | ✓ rejects world-readable secrets | -| refuse options | daemon-refuse*new*, daemon-refuse-compress | ✓ named/wildcard/allow-list | -| pre-xfer exec / post-xfer exec | daemon-exec*new* | ✓ env + abort | -| early exec | — | ✗ (needs --early-input) | -| hosts allow / hosts deny | daemon (allow), daemon-chroot-acl (deny) | `~` (needs --use-tcp for real peer) | -| reverse lookup / forward lookup | daemon-chroot-acl | `~` reverse only | -| log file / transfer logging / log format | daemon | `~` set, not asserted | -| max verbosity | daemon | `~` | -| comment | daemon, daemon-access*new* | ✓ | -| numeric ids | — | ✗ (hard to observe non-root) | -| fake super | chown-fake (client side) | ✗ as daemon param | -| timeout / max connections / lock file | — | ✗ (need --use-tcp + concurrency) | -| temp dir / open noatime / ignore errors / ignore nonreadable | — | ✗ | -| charset / name converter / dont compress | — | ✗ | -| uid / gid / daemon uid / daemon gid / daemon chroot | build_rsyncd_conf (uid/gid when root), daemon-chroot-acl | `~` root-gated | -| motd file / pid file / port / address / socket options / listen backlog / proxy protocol / syslog facility / syslog tag | — | ✗ (server-startup/connection params) | - ---- - -## Known gaps worth a future pass -* Connection/timeout params (`--timeout`, `--contimeout`, daemon `timeout`, - `max connections`) need a real socket + concurrency (run under `--use-tcp`). -* Root-only behaviours (`-o`/`--usermap` uid remap, real devices, `use chroot - = yes`, daemon uid/gid) skip as non-root; run the suite as root to cover. -* `--ignore-errors`, `-x/--one-file-system`, `--numeric-ids` have no dedicated - test yet (lower restructure risk). +# Options and daemon parameters + +Covered means a test directly checks the stated behaviour. Partial means the assertions leave part of that behaviour unchecked. Missing identifies an option or parameter without a focused test. Test names refer to scripts under testsuite/tests; forced-protocol suite runs are listed by their Make targets. + +Path depth is deep, shallow, untested or N/A. Auxiliary tree is outside, inside only, untested or N/A depending on where the relevant backup, basis, partial or temporary tree is exercised. + +## Recursion and structure + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-a, --archive | local-transfers, metadata-depth | deep | N/A | Covered for file data and metadata +-r, --recursive | local-transfers, delete-deep | deep | N/A | Covered +-R, --relative | relative-paths, relative-implied | deep | N/A | Covered with implied-directory attributes +--no-implied-dirs | relative-implied | deep | N/A | Covered for protocol 30 and later; protocol 29 rejects the multi-component case +--inc-recursive, --no-inc-recursive, --no-i-r | hard-links | deep | N/A | Partial: exercised but not isolated +-d, --dirs | nonrecursive-directories | deep | N/A | Covered without recursion +--old-dirs, --old-d | old-dirs | shallow | N/A | Covered in both mixed-version remote directions +-m, --prune-empty-dirs | prune-empty-dirs | deep | N/A | Covered with filter-emptied chains + +## Links + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-l, --links | links, symlink-ignore | deep | N/A | Covered +-L, --copy-links | links, copy-links-open-error | deep | N/A | Covered for file and directory links and preserved file-open errors +-k, --copy-dirlinks | links | deep | N/A | Covered +-K, --keep-dirlinks | symlink-dirlink-basis | deep | N/A | Covered for issue 715; unavailable without the secure path resolver +-H, --hard-links | hard-links, hard-links-depth, hard-links-emlink | deep | outside | Covered across directories and hard-link creation failure +--delay-symlinks | delay-symlinks, delay-symlinks-remote, rrsync-no-overwrite-delay-symlinks | shallow | N/A | Covered for creation order, remote propagation and restricted-shell refusal +--copy-unsafe-links | safe-links | shallow | N/A | Partial +--safe-links | safe-links | shallow | N/A | Partial +--insecure-links | operator-path-file-options, operator-path-insecure-links-refused, insecure-links-admin-optout | deep | N/A | Covered for the local opt-out and daemon refusal +--confine-root | filter-file-confinement, relative-source-ancestor, rrsync-merge-file-confine | deep | N/A | Covered for direct arguments, files-from and restricted shells +--munge-links | daemon-munge | N/A | N/A | Partial: daemon behaviour is covered but the client option is not isolated + +## Metadata, permissions and ownership + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-p, --perms | metadata-depth, symlink-receiver-permissions | deep | N/A | Covered with exact per-entry modes and unsupported symlink chmod +-E, --executability | executability | shallow | N/A | Partial +--chmod | metadata-depth, chmod-option | deep | N/A | Covered +-A, --acls | acls, acls-depth | deep | N/A | Covered when ACL support is available +-X, --xattrs | xattrs, xattrs-depth | deep | N/A | Covered when extended attributes are available +-t, --times | metadata-depth | deep | N/A | Covered +-U, --atimes | atimes | shallow | N/A | Partial +--open-noatime | open-noatime | shallow | N/A | Partial +-N, --crtimes | crtimes | shallow | N/A | Partial and platform-dependent +-O, --omit-dir-times | omit-times | deep | N/A | Covered +-J, --omit-link-times | omit-times | deep | N/A | Covered +-o, --owner | chown, ownership-depth | deep | N/A | Covered with root-gated UID mapping +-g, --group | chgrp, ownership-depth | deep | N/A | Covered with non-root group remapping +--super, --fake-super | chown, chown-fake | shallow | N/A | Partial +--numeric-ids | ownership-depth | deep | N/A | Covered for client UID and GID mapping +--usermap, --groupmap | ownership-depth | deep | N/A | Covered; user mapping needs root +--chown | ownership-depth | deep | N/A | Covered for group remapping and root-only owner remapping +-D, --devices, --specials | devices, devices-fake | shallow | N/A | Partial and privilege-dependent +--drop-D | rrsync-specials-denied | N/A | N/A | Covered for restricted receivers +--copy-devices, --write-devices | none | untested | N/A | Missing +-S, --sparse | sparse | deep | N/A | Covered with a hole at depth + +## Delta, temporary, backup and basis paths + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-T, --temp-dir | temp-dir, chmod-temp-dir | deep | outside | Covered across filesystems +--partial | partial | deep | inside only | Covered with a retained destination partial +--partial-dir | partial, symlink-dirlink-basis | deep | outside | Covered for relative and absolute partial directories with delta resume +--delay-updates | delay-updates, delay-updates-deep | deep | inside only | Covered for per-directory staging +--inplace | inplace, alt-dest | deep | inside only | Covered with inode preservation +--append, --append-verify | append, append-abort-mid-write | deep | inside only | Covered for interrupted writes; the verification split needs protocol 30 or later +-b, --backup, --backup-dir, --suffix | backup, backup-deep | deep | outside | Covered +--compare-dest, --copy-dest, --link-dest | alt-dest, alt-dest-deep, alt-dest-module-escape, operator-path-alt-dest | deep | outside | Covered for skip, copy, hard-link and confinement behaviour +-y, --fuzzy | fuzzy | shallow | N/A | Partial +-u, --update | update | deep | N/A | Covered for newer and older destinations +-W, --whole-file, --no-whole-file | local-transfers, checksum-chain-limit | N/A | N/A | Partial: forced delta transfer is covered; explicit whole-file mode is not +--mkpath | mkpath | shallow | N/A | Partial +-x, --one-file-system | none | untested | untested | Missing; needs a mount boundary +--preallocate | preallocate | deep | N/A | Covered for allocation and delta updates +--fsync | fsync-linux | shallow | N/A | Covered on Linux for successful and failed flushes +-B, --block-size | checksum-chain-limit, compress-zlib-insert, preallocate | deep | N/A | Covered +--max-alloc | max-alloc-zero | N/A | N/A | Covered for zero and values above the peer limit + +## Filtering + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-f, --filter, -F | filter-depth, multiple-sources | deep | N/A | Covered for deep per-directory merges +--exclude, --include | filter-depth, exclude, exclude-remote-shell | deep | N/A | Covered +--exclude-from, --include-from | files-from-depth | deep | N/A | Covered +-C, --cvs-exclude | cvs-exclude | deep | N/A | Covered with nested .cvsignore +--files-from | files-from-depth, daemon-files-from-subdir | deep | N/A | Covered for nested paths and daemon module subdirectories +-0, --from0 | files-from-depth | deep | N/A | Covered +--max-size, --min-size | size-filter | deep | N/A | Covered +--existing, --ignore-non-existing, --ignore-existing | delete-deep | deep | N/A | Covered +--ignore-missing-args | ignore-missing-args | deep | N/A | Covered for direct, remote-shell and files-from inputs +--delete-missing-args | delete-missing-args-files-from | shallow | N/A | Covered with files-from + +## Deletion + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +--delete, --del | delete, delete-deep | deep | N/A | Covered for deep subtrees +--delete-before, --delete-during, --delete-delay, --delete-after | delete-deep | deep | N/A | Covered +--delete-excluded | delete | shallow | N/A | Partial +--max-delete | delete-deep | deep | N/A | Covered +--remove-source-files | delete | shallow | N/A | Partial +--force | update | deep | N/A | Covered when replacing a non-empty directory with a file +--ignore-errors | iconv | N/A | N/A | Covered for deletion after sender input failure + +## Comparison, checksum and compression + +Option | Tests | Path depth | Auxiliary tree | Coverage +--- | --- | --- | --- | --- +-c, --checksum | comparison-options | deep | N/A | Covered for same-metadata content changes +-I, --ignore-times | comparison-options | deep | N/A | Covered +--size-only | comparison-options | deep | N/A | Covered +-@, --modify-window | comparison-options | deep | N/A | Covered +--checksum-choice, --checksum-seed | compress-options | deep | N/A | Covered for advertised algorithms +-z, --compress | daemon-gzip, daemon-refuse-compress | shallow | N/A | Partial +--compress-choice, --compress-level, --skip-compress | compress-options | deep | N/A | Covered +--compress-threads | daemon-refuse-compress-threads-alias, daemon-zstd-thread-exhaustion | N/A | N/A | Partial: refusal and the daemon worker limit are covered + +## Output and reporting + +Option | Tests | Coverage +--- | --- | --- +-v, --verbose | local-transfers, hard-links | Partial: verbosity levels are not compared +-i, --itemize-changes | output-options, itemize | Covered +-n, --dry-run | output-options | Covered +--stats | output-options | Covered +--out-format | output-options | Covered +--list-only | output-options | Covered +-q, --quiet | output-options | Covered +--progress, -P | output-options | Covered for progress output +-h, --human-readable, -8, --8-bit-output | output-options | Covered with format assertions +--version, --help | output-options | Covered +--info, --debug, --stderr, --no-motd, --outbuf | output-options, daemon-module-options | Covered for selection, routing, MOTD suppression and line buffering +-M, --remote-option, --log-file, --log-file-format | remote-logging | Covered for client and remote-shell sender or receiver logs + +## Batch, connection and miscellaneous options + +Option | Tests | Coverage +--- | --- | --- +--write-batch, --only-write-batch, --read-batch | batch-mode | Partial +-e, --rsh, --rsync-path | remote-shell-transfers | Covered for remote-shell transfer and deletion +--protocol | make check29, make check30 | Covered by forced-protocol suite runs +--daemon, --config, --detach, --no-detach | daemon-standalone-detach, daemon-stdin-inetd, daemon-stdin-local-socket, daemon-stdin-unix-listener, daemon-config-truncation, stdio-read-errors | Covered for startup, truncated configuration and configuration read errors +--dparam | none | Missing +--address, --port | daemon-address-family, daemon-standalone-detach | Covered for IPv4, IPv6 and configured binding +--password-file | daemon-auth, password-file-first-line | Covered for authentication and first-line parsing +--nice, --no-nice | niceness | Covered for local, remote-shell, daemon and restricted-shell priorities +--early-input | daemon-early-exec, early-input-symlink | Covered for data delivery and confined input paths +--sockopts | daemon-module-options | Covered for client and daemon configuration +--blocking-io | none | Missing +--timeout, --contimeout | daemon-handshake-timeout, io-timeout-zero, io-timeout-bounds, remote-shell-connect-timeout | Covered for precedence, bounds and remote-shell daemon paths +-4, -6, --ipv4, --ipv6 | daemon-address-family | Covered for daemon binding and client connection +--stop-after, --stop-at | stop-time | Covered for future, past and duration parsing +--bwlimit | partial | Partial: used but not asserted directly +--copy-as | none | Missing and root-gated +--iconv | iconv | Covered for names, arguments, file lists, protocol 30 link targets and invalid input; the raw-byte fixture is unavailable on macOS +-s, --secluded-args, --old-args, --trust-sender | iconv, secluded-args-transfer, cli-transfers | Partial: secluded and legacy argument handling are asserted; trust-sender is not isolated + +## Daemon parameters + +Parameter | Tests | Coverage +--- | --- | --- +path | daemon-access, daemon-listings | Covered with nested paths +read only | daemon-access, daemon-listings | Covered +write only | daemon-access | Covered +list | daemon-access, daemon-listings | Covered for hidden but usable modules +use chroot | sender-flist-symlink-leak, daemon-chroot-acl, daemon-chroot-cwd | Covered for root-directory entry, inner-module selection and failed directory changes; requires root +insecure links | insecure-links-admin-optout, daemon-symlink-escape-matrix | Covered for the administrative opt-out +munge symlinks | daemon-munge | Covered for prefix addition and removal +exclude, include | daemon-filter, daemon-listings | Covered for exclusion +filter, exclude from, include from | none | Missing as daemon parameters +incoming chmod | daemon-filter, chmod-option | Covered +outgoing chmod | daemon-filter | Covered +auth users, secrets file | daemon-auth | Covered for acceptance and rejection +auth digest | daemon-auth-digest-floor | Covered for minimum strength, old peers and invalid configuration +strict modes | daemon-auth | Covered for unsafe secrets-file modes +refuse options | daemon-refuse, daemon-refuse-compress | Covered for names, wildcards and allow lists +pre-xfer exec, post-xfer exec | daemon-exec | Covered for environment and abort behaviour +early exec | daemon-early-exec, stdio-read-errors | Covered for environment, early input and interrupted or failed input reads +hosts allow, hosts deny | daemon-access-ip, daemon-access-hostname, daemon-chroot-acl | Covered for address, CIDR and hostname rules over TCP +reverse lookup, forward lookup | daemon-chroot-acl, daemon-access-hostname | Covered for reverse lookup in a chroot and forward lookup enabled or disabled +log file, transfer logging, log format | daemon-listings | Partial: configured but not asserted +max verbosity | daemon-listings | Partial +comment | daemon-listings, daemon-access | Covered +numeric ids | daemon-name-converter, daemon-name-converter-empty-response | Partial: numeric ids = no is exercised with name conversion +fake super | chown-fake, daemon-name-converter-empty-response | Covered +timeout | daemon-handshake-timeout | Covered with zero and precedence +max connections, lock file | daemon-connection-limits | Covered for refusal and slot reuse +temp dir, open noatime, ignore errors, ignore nonreadable | iconv | Partial: ignore errors is asserted +charset | iconv | Covered for module override of the remote charset +name converter | daemon-name-converter, daemon-name-converter-empty-response, daemon-name-converter-newline | Covered for mapping, empty responses and newline-bearing requests +dont compress | daemon-module-options | Partial: configured but compression choice is not asserted +uid, gid, daemon uid, daemon gid, daemon chroot | daemon-listings, daemon-chroot-acl | Partial: transfer and access checks run but effective worker IDs are not asserted +motd file | daemon-module-options, stdio-read-errors | Covered for banner content and interrupted or failed reads +socket options | daemon-module-options | Partial: the live socket path runs but kernel effects are not inspected +pid file, port, address | daemon-standalone-detach, daemon-address-family | Covered for configuration and CLI paths +proxy protocol, proxy protocol hosts | daemon-proxy-protocol, proxy-protocol-trusted-peer | Covered for enabled, disabled and trusted-peer policy +listen backlog, syslog facility, syslog tag | none | Missing + +## Interoperability + +The interoperability test checks bytes, modes, integer timestamps, relative and dangling symlink targets, hard-link groups, deletion and checksum updates with unchanged file size and mtime. Ubuntu also exercises the fixture over restricted SSH with pinned host keys and command and path refusal checks. + +The CI Interoperability group defines 168 cases across seven native endpoints, including all 30 directed non-Linux pairs. Case completion, destination verification and network cleanup feed the Required gate. Seven-platform execution, fork-PR authentication, concurrent-run isolation and cancellation recovery remain subject to live qualification. diff --git a/testsuite/README.md b/testsuite/README.md index 3536a3660..ae45d4fbf 100644 --- a/testsuite/README.md +++ b/testsuite/README.md @@ -1,304 +1,93 @@ -# rsync testsuite +# Testing rsync -This directory holds rsync's automated regression tests. Ideally every code -change or bug fix comes with a test that would have caught the problem. - -The tests are Python scripts named `testsuite/*_test.py`, driven by the -`runtests.py` harness at the top of the tree (the old shell-based `runtests.sh` -is gone). Shared helpers live in `testsuite/rsyncfns.py`. A handful of C helper -programs (`tls`, `getgroups`, `trimslash`, …) are built alongside `rsync` and -used by some tests. Coverage notes are in [COVERAGE.md](COVERAGE.md). - -## Writing tests - -Favour readability — a test is also documentation of the behaviour it pins, so -prefer clarity over cleverness: - -* When a test writes an `rsyncd.conf`, write it as a triple-quoted f-string so - the actual config is readable top-to-bottom, with module parameters indented - with plain spaces. Don't build it from adjacent string literals full of `\n` - (and `\t`) escapes. The daemon's parser accepts space-indented parameters. -* Better still, use the structured helpers in `rsyncfns.py` when a stock config - will do: `write_daemon_conf(modules, globals)` (per-test modules/params) or - `build_rsyncd_conf()` (the four standard modules). They also handle the - root-only `uid`/`gid` lines for you (needed so a `use chroot = no` daemon run - as root can read a root-owned module). -* For config that varies (e.g. those root-only `uid`/`gid` lines), interpolate a - single optional block that expands when needed and is an empty string - otherwise, rather than splicing pieces together: - - ```python - root = get_testuid() == get_rootuid() - ids = f"uid = {get_rootuid()}\ngid = {get_rootgid()}" if root else "" - conf.write_text(f"""\ - pid file = {base}/rsyncd.pid - use chroot = no - {ids} - log file = {base}/rsyncd.log - - [m] - path = {mod} - read only = yes - """) - ``` - -## Running the tests - -### Via make - -Run from the build directory: - -- **`make check`** — build the helper programs and run the whole suite in - parallel (`CHECK_J`, default 8) against the just-built `./rsync`. You do **not** - need `make install` first; indeed you generally should not install before - testing. Use `make check CHECK_J=1` to run serially. -- **`make check29`** / **`make check30`** — the same, forcing protocol version 29 - or 30. -- **`make installcheck`** — run the suite against the *installed* binary (e.g. - `/usr/local/bin/rsync`). Per the GNU standards this does not search `$PATH`. - Handy for testing a distribution build. -- **`make check-progs`** — (re)build just the C helper programs the tests need, - without running anything. -- **`make coverage`** / **`coverage-tcp`** / **`coverage-all`** — generate an HTML - coverage report (needs `./configure --enable-coverage` and `gcovr`); - `coverage-all` merges runs across protocol versions and the tcp transport. - -### Via runtests.py directly - -`make check` just drives `runtests.py`; run it directly for finer control. It -defaults `--rsync-bin` to `./rsync`, so run it from the build directory (or pass -`--rsync-bin` / `--tooldir`): +## Running tests +From a configured build directory: ```sh -./runtests.py # all tests -./runtests.py chmod-temp-dir # a single test by name -./runtests.py 'xattr*' # a glob of test names +make check ``` -Useful options: - -- `-j N`, `--parallel N` — run up to N tests at once -- `--use-tcp` — run daemon tests against a real `rsyncd` on `127.0.0.1` (the - default runs them over a stdio pipe). **Read the security warning below before - using this on a shared machine.** -- `--protocol VER` — force a protocol version -- `--preserve-scratch` — keep each test's scratch dir afterwards -- `--log-level N`, `--always-log` — more verbose output / show logs for passing tests too -- `--stop-on-fail` — stop after the first failure -- `--timeout SECS` — per-test timeout (default 300) -- `--timing` — after the run, list the tests by wall-clock, slowest first, with - the serial sum and the floor set by the single longest test -- `--race-timeout SECS` — budget a TOCTOU race test may spend trying to win its - race. These are the suite's slowest tests: a race test is a negative oracle, - so it passes by spending its *whole* budget (5–15s each by default). Lowering - this speeds the suite up and weakens the oracle in equal measure. -- `--valgrind`, `--valgrind-opts OPTS` — run rsync under valgrind -- `--rsync-bin PATH`, `--tooldir DIR`, `--srcdir DIR` — locate the binary / build / source dirs -- `--expect-skipped LIST` — see skip enforcement below - -### Security warning: `--use-tcp` - -> **⚠️ Do not use `--use-tcp` on a machine with untrusted local users.** -> -> `--use-tcp` starts a real `rsync` daemon listening on a loopback TCP port -> (`127.0.0.1` / `::1`) and **deliberately configures insecure test scenarios** -> (daemon modules without authentication, unsafe options enabled, etc.). Loopback -> addresses are reachable by *every* local user, so for as long as the tests run, -> any other user on the machine can connect to that daemon and exploit those -> deliberately-insecure modules — potentially reading or writing files with the -> privileges of the user running the tests (which is **root** if you run the suite -> as root). -> -> Only run `--use-tcp` where there are **no possible local users who might try to -> exploit it** — a single-user workstation or a dedicated, isolated CI machine. -> The default stdio-pipe transport carries no such risk: it talks to the daemon -> over a private pipe with nothing listening on the network, so prefer it on any -> shared or multi-user host. - -### Results and exit codes - -Each test prints one result line — `PASS`, `FAIL`, `ERROR`, `SKIP` (with a -reason), or `XFAIL` (an expected failure) — and the run ends with a -`passed / failed / skipped` summary. Per-test exit-code convention: - -| code | meaning | -|------|---------| -| 0 | pass | -| 1 | fail | -| 2 | error | -| 77 | skip | -| 78 | xfail | - -`runtests.py` exits non-zero if any test fails. Some tests need root or another -precondition and otherwise `SKIP` — read the individual test scripts for details. - -**Skip enforcement:** on a full run, set `RSYNC_EXPECT_SKIPPED=a,b,c` (or -`--expect-skipped a,b,c`) and the run fails if the set of skipped tests does not -match. This is how the CI workflows pin each platform's expected skip set. An -`@FILE` entry reads a skip list (one test per line) instead, and several may be -composed: the workflows use -`@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt`. Keeping the -lists one-name-per-line is what stops two branches that each add a skipping test -from conflicting -- see `testsuite/skiplist/README.md`. - -### Scratch dirs and debugging - -Each test runs in `testtmp//`. On failure the scratch directory is left in -place (also `--preserve-scratch`); including its logs in a bug report is helpful. +This builds rsync and its C test helpers, then runs up to eight tests at once. Set CHECK_J to change the parallelism. The protocol targets run the suite with a forced wire version: +```sh +make check29 +make check30 +``` -### Preconditions +After the helpers are built, the runner accepts individual test names and shell patterns: +```sh +./testsuite/runtests.py -j8 chmod-temp-dir 'xattr*' +./testsuite/runtests.py --help +``` -You need `python3`, `/bin/sh`, and the normal build toolchain. The ACL/xattr -tests need the `acl` and `attr` tools (`getfacl`/`setfacl`, -`getfattr`/`setfattr`) and skip if they are absent. Some tests need root. +Direct runner invocations are serial unless given `-j`. Out-of-tree builds use the source path to runtests.py with `--srcdir`, `--tooldir` and `--rsync-bin`. Some mutation tests require the source directory to be configured too. The installcheck target tests the installed binary. -These tests also run in CI via GitHub Actions (see `.github/workflows/`). +The runner, tests and CI helpers use Python 3.7 or later and its standard library. CI checks Python 3.7 with the harness self-tests, CLI transfers and CI helper tests. Test subprocesses disable site packages with `-S`. Shell remains in package bootstrap, build commands and tests which exercise shell behaviour. CommonMark is a man-page build dependency. ACL and extended-attribute tests need the corresponding platform tools. Linux root permission tests require setpriv. -## Fleet testing (fleettest.py) +Scratch data defaults to `testtmp//`. Failed tests retain their scratch directories. Root and non-root runs need separate scratch directories. -`testsuite/fleettest.py` builds the committed HEAD of an rsync checkout on a -fleet of remote machines over ssh and runs the suite under both transports -(stdio-pipe and `--use-tcp`) in parallel, reporting only the *unexpected* -results. It is a fast local pre-flight for the GitHub CI matrix: each target -mirrors a `.github/workflows/*.yml` job — its configure flags, and the -`RSYNC_EXPECT_SKIPPED` list parsed straight from the workflow. +During execution, SIGTERM stops queued tests and terminates active test process groups. Incomplete runs fail. -Because every run includes a `--use-tcp` pass, the fleet stands up the insecure -loopback test daemon on each target — so only point it at machines with **no -untrusted local users** (see the [security warning](#security-warning---use-tcp) -above). +The `--use-tcp` option starts unauthenticated test daemons on loopback addresses and some fixtures enable unsafe daemon options. Other local users can reach those listeners. Use the default pipe transport on shared hosts. The `--daemon-tests-only` option limits a follow-up TCP pass to tests which can observe the transport choice. -The fleet — which machines, and how to reach and build on each — is described in -a JSON file. Copy the bundled example (it is git-ignored) and edit it for your -hosts: +Ubuntu also runs tools/check-ssh.py against the installed binary. It uses OpenSSH with temporary host and client keys, pins the host key and restricts commands through support/rrsync. It checks transfers, deletion, checksum updates and refusals for arbitrary commands, parent-directory access and incorrect host keys. The listener is loopback-only and all keys are removed after the check. -```sh -cp testsuite/fleettest.json.example testsuite/fleettest.json # then edit -# (or symlink it, or point elsewhere with --fleet PATH) -``` +Historical binaries live in `old_versions/`. The runner's `--rsync-bin2` option selects the secondary binary and `--archived-role` identifies which side uses the archived release. The static Linux X86-64 peers omit OpenSSL and IDN support. -The config is looked up in order: `~/.fleettest.json` first, then -`testsuite/fleettest.json`, unless overridden with `--fleet PATH`. +## Writing a test -Each entry names an ssh host (`null` to run locally), the workflow it mirrors, -and its configure flags, plus optional per-target settings (`make`, `privilege`, -`env_prefix`, …). See the comments in `fleettest.json.example`. +Tests live under testsuite/tests in the build, daemon, metadata, path, protocol, rrsync, selftests and transfer groups. The selftests group checks the harness and its tools. Reusable support lives in testsuite/harness; fixture data and specialised fixture builders live in testsuite/fixtures. Filenames describe the behaviour, use lowercase words separated by hyphens and must be unique across groups. Selectors, profiles and receipts use the filename without its `.py` ending. [COVERAGE.md](COVERAGE.md) lists the option and daemon-parameter assertions. -A target with `"nonroot": true` does an extra pass, after the main (root) run, -that reruns the privilege-sensitive tests as the unprivileged ssh user. Which -tests those are is **not** listed in the fleet config — a test opts in by -setting a module-level `fleet_nonroot = True`, so the set is maintained in the -test files and new privilege-sensitive tests join automatically with no -fleet-config change. +Bug fixes should include a regression which fails for the original bug. Assert the affected behaviour directly; a final source and destination comparison can miss ordering, confinement or reporting failures. -A target with `"protocols": [30, 29]` runs one extra stdio-pipe pass per listed -version, each forcing that older wire version with `runtests --protocol=N` — the -fleet analogue of a workflow's `check30`/`check29` steps. Each pass takes the -`RSYNC_EXPECT_SKIPPED` spec from the workflow's own `check30`/`check29` step, so -a lane with extra protocol-gated skips (`check29` adds -`@testsuite/skiplist/proto29.txt`) is enforced correctly. They show up as -`protoNN` columns in the report (and `--timing` breakdown); targets that don't -set `protocols` show `-` there. +Function-based tests use one `@requires(...)` decorator. Module tests use one top-level `metadata(...)` call. Declare required features, transports, protocols, peer versions, capabilities and tags. A `min_peer` value may use separate `primary` and `secondary` floors. Declare any capability passed to `test_skipped()`. Use `require_tcp()` and `require_asan()` for those requirements. Security, compatibility and cost remain metadata rather than separate test groups. -Run it from inside a checkout (it builds the current directory's HEAD; use -`--repo PATH` for another tree): +Use TestContext or the paths in harness.environment for scratch data and `write_daemon_conf()` or `build_rsyncd_conf()` for ordinary daemon configurations. Tests must tolerate parallel execution and clean up their processes, sockets and temporary files. Do not write into the source tree or use sleeps to force ordering or timestamp changes. -```sh -python3 testsuite/fleettest.py # whole fleet, both transports -python3 testsuite/fleettest.py --list # list configured targets -python3 testsuite/fleettest.py --targets NAME[,NAME] -python3 testsuite/fleettest.py --fleet other.json --transport pipe -python3 testsuite/fleettest.py --timing # per-target wall-clock breakdown -python3 testsuite/fleettest.py --keep-on-fail # keep logs + tree where it broke -python3 testsuite/fleettest.py --full-tcp # whole suite in the tcp pass too -``` +## Profiles and results -`--timing` adds a per-target breakdown after the report — total wall-clock plus -the push / build / pipe / tcp / protoNN / nonroot phases, sorted slowest-first. Targets -run in parallel, so the whole run is gated by the slowest one; the phase columns -show whether that target's hold-up is the push, the build, or a test pass. It -also passes `--timing` down to each target's `runtests.py`, so the captured -output attributes a slow pass to individual tests. +Profiles compose by name through `--profiles`. Platform profiles describe permitted capability gaps; peer profiles identify an archived version and protocol. The root and nonroot profiles must match the effective UID and cannot be combined. Peer runs select applicable tests from version-mix metadata. -The `tcp` pass runs **only the tests that can reach the daemon transport**, since -it follows a full pipe pass over the very same build. `--use-tcp` is observable -through exactly one path — `RSYNC_TEST_USE_TCP` is read once in `rsyncfns` -(`USE_TCP`) and acted on once, in `start_test_daemon()` — so a test that never -gets there produces an identical result twice. That drops 186 of the 340 tests -and roughly a third of the pass's work; the count skipped is always printed. -Pass `--full-tcp` to sweep the whole suite there anyway. The narrowing applies -only when both transports run: under `--transport tcp` that pass is the only -one, so it runs the whole suite regardless. +CI invokes [tools/check.py](tools/check.py) with Python to run composed profiles. It takes the build directory, binary, profile list and a fresh output directory, followed by runner options. Each run saves run.log, receipt.json and isolated scratch data. The launcher checks the receipt's results, executable identity and profiles before returning. -`--keep-on-fail [DIR]` makes a failure inspectable without repeating the run. -For every target that came back with anything unexpected it writes the full -build and per-transport output to `DIR///` (default -`./fleettest-logs`) and keeps that target's remote run dir, with the scratch -trees its failing tests left behind. Targets that came back clean are swept as -usual. This matters most for the race tests, which may not fail the same way -twice — and because a re-run costs a full configure + build on every machine. +Label | Meaning +--- | --- +N/A | The test does not apply to the active profile and is not executed +PASS | The assertion passed +FAIL | The assertion failed +ERROR | The test environment or harness failed +SKIP | The test skipped without identifying a missing capability +UNSUPPORTED | A declared capability was unavailable +PROFILE_ERROR | The result disagreed with the active profile -Each run gets its own randomly-named build dir on every target -(`-`), so two or three runs can share the same fleet without -interfering. The dir is removed when the run ends — on success or failure, and -best-effort on Ctrl-C/kill; pass `--keep` to retain it for inspection. A hard -kill (`SIGKILL`), or a signal arriving mid-push, can leave a stray -`-` behind; sweep leftovers with -`python3 testsuite/fleettest.py --cleanup` (scope it with `--targets`, and only -run it when no other fleet runs are active, since it removes *all* matching run -dirs on the selected targets). +With profiles active, an unsupported result is accepted only when the test declares the capability and the profile permits its absence. A generic skip is a profile error. Failures and errors always fail the run. Without profiles, skips and unsupported results are accepted. -Each target must be provisioned with the build toolchain its workflow installs -(autoconf, automake, a C compiler, perl, a python3 markdown module such as -cmarkgfm or commonmark unless the flags pass `--disable-md2man`, and the dev -libraries its configure flags enable). A missing piece shows up as `BUILD-FAIL`. +Individual tests use exit codes 0 for pass, 1 for failure, 2 for error and 77 for skip. Receipts retain raw outcomes, profile verdicts, N/A selections, executable hashes and the effective UID. Source revision and working-tree state are recorded when Git metadata is available. -## Differential regression hunting (abdiff.py) +## CI -`testsuite/abdiff.py` is a developer tool — **not** a `*_test.py`, so `runtests.py` -ignores it. It hunts *regressions* by running the **same benign transfer** with -two rsync binaries (`A` = the build under test, `B` = a baseline) and comparing -the OUTCOME. The oracle is: for a benign input, a correctness/behaviour change -between the builds must be **invisible**, so A and B must produce an identical -result. Any divergence is a regression candidate to investigate and, if real, -minimize into a `*_test.py`. +[ci.yml](../.github/workflows/ci.yml) selects native tests, diagnostics, archive qualification and historical-peer checks for pull requests and master pushes. Scheduled runs divide daily and weekly work. The Versions group runs every archived release as both the primary and secondary executable over pipe and TCP. Valgrind runs root and non-root pipe and TCP checks in its own group. Clang 18 and Clang 21 are blocking checks. -It compares exit code, stderr (error markers + normalised text), `--stats` -"Literal data", the destination tree (content + full metadata: mode/uid/gid/ -mtime/size/symlink target/xattrs/ACLs/hardlink grouping), the `--itemize` list, -and — with `--cost` — peak process-group RSS (a resource-regression oracle that -functional comparison misses). A **stability gate** runs each binary several -times and escalates on a candidate diff; nondeterministic scenarios are -quarantined `FLAKY`, never reported as regressions. +The Interoperability group runs Linux, FreeBSD, NetBSD, OpenBSD, macOS, Solaris and Cygwin endpoints within PR CI, including fork PRs. Its 168 cases cover every directed platform pair, both client roles and SSH and daemon transports. A separate trusted controller handles network enrolment, admission and cleanup without executing candidate code. [Network access and recovery](../.github/INTEROPERABILITY.md) define the authentication and failure contracts. -Run it from the build directory (so `./rsync` and `old_versions/` resolve): +The interoperability test qualifies a common transfer fixture through native daemon pushes and pulls. It checks file data, modes, timestamps, hard links, symlink targets, checksum updates and deletions. The shared case plan covers all 42 directed pairs between Linux, FreeBSD, NetBSD, OpenBSD, macOS, Solaris and Cygwin, both client roles and SSH and daemon transports. Connected execution of those 168 cases remains the next stage. -```sh -testsuite/abdiff.py # default: ./rsync vs old_versions/rsync_3.4.1 -testsuite/abdiff.py --sweep all -j5 # broad single pass, 5-way parallel -testsuite/abdiff.py --loop --timelimit 3600 --cost # hunt for an hour, resource oracle on -testsuite/abdiff.py --list --sweep all # list scenarios without running -``` +Environment | Versions and architectures +--- | --- +Ubuntu | 22.04, 24.04 and 26.04 on X86-64 and ARM64 +AlmaLinux | 8 on X86-64 +Alpine | 3.24.4 on X86-64 and X86-32 +FreeBSD | 14.5 and 15.1 on X86-64 +NetBSD | 10.2, 11.0 and 11.1 on X86-64 +OpenBSD | 7.9 and 8.0 on X86-64 +macOS | 15 and 26 on X86-64 and ARM64 +Cygwin | X86-64 on Windows Server 2022 +Solaris | X86-64 guest using the VM action's default release +Android | API 24 Bionic on ARM64, ARMv7 and X86-64; Termux userspace and an API 36 emulator on X86-64 -Each finding is classed `DIFF` (regression candidate), `ALLOW` (an intentional, -documented behaviour change listed in the tool's allowlist), `BETTER` (A succeeds -where B fails), `FLAKY`, or `TIMEOUT`. Findings are printed and appended to a -per-run `abdiff-log_