mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-14 14:18:23 -04:00
parse_one_refuse_match() marked only the first long_options row whose long name matched the configured spelling, then broke out for a non-wildcard rule. --compress-threads and --zt are separate popt rows that both write &do_compression_threads, so "refuse options = compress-threads" disabled the canonical row and left the alias accepted: the refused capability was still reachable under its other name. The same shape covers zc/compress-choice and zl/compress-level. An exact rule names a capability, not one spelling of it, so mark every row that does the same thing. Comparing the raw table fields is not enough for that: popt's `val` means different things per argInfo. For POPT_ARG_VAL it IS the value stored in `arg`, while elsewhere a nonzero `val` is an action code for the parser's switch, and POPT_ARG_NONE with a destination stores 1 whatever `val` says. --del is POPT_ARG_NONE/&delete_during/0 and --delete-during is POPT_ARG_VAL/&delete_during/1: the same destination and the same resulting value, but unequal as table entries, so "refuse options = delete-during" was still evaded by --del and the mirror held too. Compare what a row does instead -- the destination and the constant it assigns, falling back to table-entry equality for rows that store a runtime value or only dispatch an action. Enumerating all 258 rows, this couples exactly one pair the field comparison missed, del and delete-during, and changes nothing else. Opposite switches such as --foo and --no-foo stay distinct because they assign different values. Two regressions. The compress-threads one drives the raw daemon protocol -- not to preserve the spelling, which -M--zt=N would do just as well, but because it goes on to observe the worker pool the bypass delivers. Its oracle is the refusal itself -- the alias connection torn down and "configured to refuse --zt" logged -- and deliberately not the resulting worker count: an accepted --zt is a defeated refuse rule however few threads it produces, and the daemon worker cap being added alongside this holds that count to 9, so a count-based assertion passes while the alias is still accepted. Run that test against the cap without this parser fix and it does exactly that; the two changes were covering for each other. The delete one needs neither zstd nor a socket: --remote-option puts the option in the daemon's argv verbatim, which is the reach an ordinary user already has, so it drives a stock client both ways round against modules refusing each spelling, with an unrefused module as the control. The compress-threads test needs --use-tcp, so it skips in every other column and is declared in the workflows that enforce a skip set, which a fleet run otherwise reports as an unexpected skip on fourteen cells.
110 lines
4.7 KiB
Python
110 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""A refuse rule naming one spelling of an option must cover the others.
|
|
|
|
--del and --delete-during are the same capability written two ways, but the
|
|
popt table spells them differently: --del is POPT_ARG_NONE with a destination
|
|
(popt stores 1), --delete-during is POPT_ARG_VAL storing 1. A refuse rule
|
|
matched on the raw table fields therefore disabled only the spelling the
|
|
administrator happened to write, and the other one still worked.
|
|
|
|
Both directions are checked, because the mismatch is symmetric: a rule naming
|
|
the canonical option was evaded by the short alias, and a rule naming the
|
|
alias was evaded by the canonical option.
|
|
|
|
No raw protocol here, and no custom client: --remote-option (-M) puts the
|
|
option in the daemon's argv verbatim, which is exactly the reach an ordinary
|
|
user has.
|
|
"""
|
|
|
|
import subprocess
|
|
|
|
from rsyncfns import (
|
|
SCRATCHDIR, makepath, rmtree, rsync_argv, start_test_daemon, test_fail,
|
|
write_daemon_conf,
|
|
)
|
|
|
|
PORT = 12951
|
|
|
|
base = SCRATCHDIR / 'daemon-refuse-delete-alias'
|
|
rmtree(base)
|
|
src = base / 'src'
|
|
makepath(src)
|
|
(src / 'keep').write_text('KEEP\n')
|
|
|
|
# Two modules, each refusing one spelling of the same capability.
|
|
mods = {}
|
|
for name, rule in (('refuses_canonical', 'delete-during'), ('refuses_alias', 'del')):
|
|
d = base / name
|
|
makepath(d)
|
|
mods[name] = d
|
|
|
|
conf = write_daemon_conf([
|
|
('refuses_canonical', {'path': str(mods['refuses_canonical']), 'read only': 'no',
|
|
'use chroot': 'no', 'refuse options': 'delete-during'}),
|
|
('refuses_alias', {'path': str(mods['refuses_alias']), 'read only': 'no',
|
|
'use chroot': 'no', 'refuse options': 'del'}),
|
|
# No rule at all: proves the pushes themselves work, so a refusal below
|
|
# is the rule firing rather than the transfer being broken.
|
|
('open', {'path': str(base / 'open'), 'read only': 'no', 'use chroot': 'no'}),
|
|
], name='refuse-delete-alias.conf')
|
|
makepath(base / 'open')
|
|
url = start_test_daemon(conf, PORT)
|
|
|
|
|
|
def push(module, *opts):
|
|
return subprocess.run(
|
|
rsync_argv('-r', *opts, f'{src}/', f'{url}{module}/'),
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=120)
|
|
|
|
|
|
def refused(proc, spelling):
|
|
"""Did the daemon refuse this exact spelling?
|
|
|
|
Requiring the option name, not just the word "refuse", keeps an unrelated
|
|
refusal from standing in for the one under test -- and it doubles as proof
|
|
that -M actually delivered the option to the daemon, which the control
|
|
below cannot show on its own: a silently discarded -M would let the
|
|
transfer succeed just the same."""
|
|
return (proc.returncode != 0
|
|
and f'configured to refuse --{spelling}' in proc.stdout)
|
|
|
|
|
|
# Control: both spellings transfer where nothing refuses them, so a refusal
|
|
# below is the rule firing rather than the push being broken. That -M reached
|
|
# the daemon at all is established by the spelling-specific refusals, not here:
|
|
# a silently dropped -M would pass this control too.
|
|
for opt in ('-M--del', '-M--delete-during'):
|
|
proc = push('open', '--delete', opt)
|
|
if proc.returncode != 0 or not (base / 'open' / 'keep').is_file():
|
|
test_fail(f'control failed: {opt} could not push to a module with no '
|
|
f'refuse rule (rc={proc.returncode}, '
|
|
f'output={proc.stdout.strip()[:300]!r})')
|
|
|
|
# The reported direction: the rule names the canonical option, the client
|
|
# sends the alias.
|
|
proc = push('refuses_canonical', '--delete', '-M--del')
|
|
if not refused(proc, 'del'):
|
|
test_fail('"refuse options = delete-during" did not refuse --del, which '
|
|
'sets the very same delete_during: the rule names a capability, '
|
|
f'not one spelling of it (rc={proc.returncode}, '
|
|
f'output={proc.stdout.strip()[:300]!r})')
|
|
|
|
# ...and the mirror, since the table shapes differ in both directions.
|
|
proc = push('refuses_alias', '--delete', '-M--delete-during')
|
|
if not refused(proc, 'delete-during'):
|
|
test_fail('"refuse options = del" did not refuse --delete-during '
|
|
f'(rc={proc.returncode}, output={proc.stdout.strip()[:300]!r})')
|
|
|
|
# And each rule still refuses the spelling it actually names.
|
|
proc = push('refuses_canonical', '--delete', '-M--delete-during')
|
|
if not refused(proc, 'delete-during'):
|
|
test_fail('"refuse options = delete-during" did not even refuse '
|
|
f'--delete-during (rc={proc.returncode}, '
|
|
f'output={proc.stdout.strip()[:300]!r})')
|
|
proc = push('refuses_alias', '--delete', '-M--del')
|
|
if not refused(proc, 'del'):
|
|
test_fail(f'"refuse options = del" did not even refuse --del '
|
|
f'(rc={proc.returncode}, output={proc.stdout.strip()[:300]!r})')
|
|
|
|
print('a refuse rule for one spelling of --delete-during covers --del too')
|