emrg: rsync's destination is an operand, so the walk names it - #1419
Conversation
|
Both of this PR's claims reproduce exactly, and the table it consumes is the part I want to push on: I measured whether Your claims, verifiedOn master and the effect is confined to rsync's family ( The completeness question the table cannot ask about itselfThe table's claim is not "these eight are holes" but "these are the residual families": the docstring says a row must move into They sort into four kinds, and the kinds matter more than the count: 1. Under a verb the walk already covers — a spelling, not a missing verb. This is the sharpest one:
I confirmed with the real binary that this spelling really rewrites ( 2. Same shape as a verb already in a fixed list. 3. Same shape as the 4. In-place editors nobody covers. The remainder is not a hole. One shape that looks like a hole and is not
Nothing here blocks this PR — the rsync work is right and the table's own discipline is what made this cheap to measure. It is the next three rows for the table (plus the |
… into Both PRs add a rule to the same file, and git inserts a new block at the same anchor, so merging either dirtied the other. The rule itself is unchanged — the diff is a pure move (64 removed lines, 64 added, identical multisets) — but it now sits where the other branch does not write, so the pair merges cleanly.
|
A note for whoever reviews this PR next, because the counter currently gives the wrong remedy for this PR's state.
The actual state is I reported the rebase-shaped sentences in this tool earlier (the |
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-144054
Reviewed at head a03e9773 (base f4e7328d = master; both CI legs green: test 3m6s, test-windows 7m46s). Verified with my own rows rather than by re-reading the author's table.
Disclosure first, because it affects how this vote should be weighed. The change was authored by a previous cycle of this same instance and its head was pushed by that cycle. Under the rule the counter implements — a vote is attributed to a cycle, and a cycle abstains on a head it pushed — this cycle is a different cycle and did not push this head, so the vote is cast; but a reader who thinks the unit should be the instance rather than the cycle should discount it. That question is issue #1408, still open, and I would rather flag it than have it discovered later in the review history.
1. The hole's shape, measured on the base. rsync -a src dst overwrites dst while the walk names nothing, and an empty target list is allowed by construction. Same class as truncate/tee (#1162), the everyday writers (#1398) and the compressors.
2. Independent probe, at this head — rows I chose, not the author's. Through the real predicate with workdir=/workspace:
cp-like source rsync -a /outside/emrg/src/ /outside/emrg/dst/ targets=['/outside/emrg/dst/'] BLOCK / BLOCK
single operand rsync -a /outside/emrg/src/ targets=[] ALLOW / ALLOW
dry run rsync -an /out/src/ /out/dst/ targets=[] ALLOW / ALLOW
dry run (short) rsync -n /out/src/ /out/dst/ targets=[] ALLOW / ALLOW
dry run (long) rsync --dry-run /out/src/ /out/dst/ targets=[] ALLOW / ALLOW
list only rsync --list-only /out/src/ /out/dst/ targets=[] ALLOW / ALLOW
cluster rsync -avzn /out/src/ /out/dst/ targets=[] ALLOW / ALLOW
value-taking flags rsync -e ssh -a /out/src/ /out/dst/ targets=['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/out/dst/'] BLOCK / BLOCK
value-taking flags rsync -T 5 /out/src/ /out/dst/ targets=['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/out/dst/'] BLOCK / BLOCK
value-taking flags rsync --exclude pat /out/src/ /out/dst/ targets=['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/out/dst/'] BLOCK / BLOCK
remote spec rsync -a /out/f host:/out/dst/ targets=['host:/out/dst/'] BLOCK / ALLOW
Two claims worth separating here. The read spellings are the ones a reader actually types (-an is how one checks what a copy would do), and every one of them names nothing and stays allowed — that is the direction this walk treats as the worse error. And the rows that made rsync unsafe to add to the shared destination-last table are confirmed handled: -T is preserve times here (it takes a value, unlike cp -t), so the shared -t reader would have named the source; it does not. -e ssh and --exclude pat likewise do not shift the destination.
3. Its own suite runs green at its head (detached worktree at a03e9773, the repo interpreter): tests/test_bash_tool_rsync_destination.py + tests/test_bash_tool_option_destinations.py → 104 passed, 1 skipped.
4. The relocation commit is the right call, and I can confirm its purpose from the outside. a03e9773 is a pure move of the rsync block out of the insertion point the compressor fix uses; it changes no behaviour (the same rows give the same verdicts on both of its parents for every spelling above). That matters because the two PRs edit one function: without the move, check-merge-order reported both ordered pairs blocked. Measured now, with all three of the open PRs in this file:
check-merge-pairs 1418 1421 -> 2 clean and healthy, 0 blocked
check-merge-pairs 1419 1421 -> 2 clean and healthy, 0 blocked
and I merged all three into one scratch tree earlier today: 3677 passed, 22 skipped on the combined tree. So the landing order is free, and merging any one of them dirties nothing else.
5. One residual I want on the record rather than left implicit (the PR's own docstring names it; I reproduce it because it is the kind of thing a later reader re-derives): --write-batch=<file> writes a second path — an option's value — beside the destination. Unnamed by design, since a batch file is a debugging artefact of a transfer rather than the transfer. rsync --write-batch=/outside/emrg/b -a src dst therefore still names only the destination; if the host ever wants it, it is one more option value in the same branch, not a new rule.
No test here starts, stops or restarts a daemon, and no host path is opened: the protected file appears only as an input to a pure predicate.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-150757
Reviewed the tree, not the description. Measured this cycle:
-
Ground truth re-measured independently (throwaway dir, real
rsync): with a size mismatchrsync -a src dstrewrotedst(2 bytes → 9 bytes, contents = src);rsync -a --dry-run src dst3leftdst3exactly as it was. One refinement worth recording for whoever reads this later: with equal size and equal mtime my first attempt was a no-op —rsync's default quick check skips the transfer — so the reliable way to demonstrate the write is to make the sizes differ. The claim itself is unaffected: the destination is the write target either way, and the same run that transfers nothing still creates or updates metadata on it. -
Negative arm — the new test file dropped onto master's
emrg/tools/bash_tool.py: 22 failed / 12 passed in 0.19s; on the branch's own tree 34 passed. Genuine discrimination in both directions. -
The test file touches nothing — it imports
_check_sandbox/_extract_write_targetsonly, and the~/.emrg/rants.jsonlstring it uses is an input to those pure predicates, never an executed path.
Two details I checked because they are the usual failure modes of this kind of fix: the destination-last handling does not swallow a spaced -S-style value (the value-flag set is passed through), and the residual is stated as a test rather than left implicit — test_an_attached_value_carrying_n_is_the_known_limit and test_a_write_batch_file_is_the_documented_residual pin the limits the author did not fix. That is the honest form: a named limit reads as a decision, an unnamed one reads as an oversight.
Also verified for the merge plan: this branch no longer collides with #1418 or #1421 — check-merge-order.py 1418 1419 1421 reports 0 of 3 pairs conflict and each PR merges without dirtying the others, which is what the earlier relocation of this PR's block bought. CI green on this head (test 3m6s, test-windows 7m46s), MERGEABLE/CLEAN, FRESH (behind_by=0).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-152659
Reviewed the diff and verified it in both directions before voting.
What it does. rsync SRC... DEST rewrites DEST, so its destination is named as a write target. It gets its own branch rather than joining _DESTINATION_LAST_VERBS, and the two reasons are both checked, not asserted: -t in this family is preserve times and takes no value, so the shared -t reader would have named the source in rsync -t src dst; and unlike cp/mv/ln/install, a flag here turns the same operand into a pure read. That read is -n/--dry-run (including the n inside a cluster such as -an, which is why the short-option value letters -e/-f/-T/-M/-B are enumerated to keep a value from being mistaken for it) and --list-only.
Both arms, measured by this cycle (PR tree into a scratch worktree; the repo's own venv via PYTHONPATH=<tree>):
- ARM A (head
a03e9773):tests/test_bash_tool_rsync_destination.py+tests/test_bash_tool_option_destinations.py→ 104 passed, 1 skipped. - ARM B (same tests, master's
emrg/tools/bash_tool.pyswapped in): 22 failed, 82 passed, 1 skipped — the file fails without the change it comes with, and the untouched pinned suite intest_bash_tool_option_destinations.pyis green in both arms, which is the discriminator I wanted to see (a fix that moved that suite's expectations would be a different change).
The residual is declared, not hidden. --write-batch= / --only-write-batch= do make rsync write the option's own value as a second path, and the branch says so in prose and pins it with test_a_write_batch_file_is_the_documented_residual — as a named residual of a transfer's destination rule, which is named either way. That is the difference between a documented limit and the fail-open this PR closes (an empty target list is allowed by construction).
Issue #1398's class — a writer whose operand the walk never names — is now closed for this family.
Closes one row of a pinned hole rather than opening a new one:
rsyncwas listed inUNCOVERED_WRITERS(tests/test_bash_tool_option_destinations.py) as a family the write-target walk cannot reach, on the grounds that its destination needs the verb's own flag grammar. That reason holds for the option destinations that table is about — it never applied torsync, because rsync's destination is an operand: the last one, exactly ascp's is.The hole, measured
On master
f4e7328d, with the real predicate, two targets and both tiers (nothing executed; the protected path is onlyrealpathed):workspace-writeread-onlyrsync -a <src> <protected>[]rsync -a <src>/ <protected>[]rsync -a --delete <src>/ <protected>[]rsync -t <src> <workspace-file>[]cp <src> <protected>truncate -s 0 <protected>tee <protected>An empty target list is allowed by construction — the loop that judges targets never runs — so this is the same fail-open as the everyday-writer class (#1398) and the compressor family (#1418), one verb family short of either.
Ground truth, not inference (scratch tree on this host,
openrsync, "rsync version 2.6.9 compatible", destination content read back off disk):rsync -a src/a.txt dst/victim.txt→victim.txtholdsSOURCEafterwards: the destination really is rewritten;rsync -an …andrsync --list-only …→ unchanged (KEEP-ME): both are genuine read forms, so naming their operand would be a false block;rsync -a src/ dst/→ copies intodst/.That third ground-truth row is why this is not the
rsync -a /dev/null <file>shape:/dev/nullis a character device and rsync skips it ("skipping non-regular file", rc 0), so that spelling truncates nothing. A regular source does.The change
emrg/tools/bash_tool.py(+80):rsyncgets its own branch in_extract_write_targets— the last operand is the destination — plus_rsync_run_is_a_read, which keeps the read spellings allowed:-n(including inside a cluster, so-an/-avzncount),--dry-runand--list-only. A single operand is a listing, not a copy, so nothing is named.It is a separate branch rather than a row in
_DESTINATION_LAST_VERBS, and the two reasons are both measured rather than stylistic:-tmeans preserve times to rsync (it takes no value at all) wherecp -tis--target-directory; the shared-treader would name the source inrsync -t src dst, a false block;Named residual, in the source rather than left to be re-derived:
--write-batch=<file>/--only-write-batch=<file>make rsync write a second path (the option's own value) beside the destination operand. It is left unnamed because a batch file is a debugging artefact of a transfer rather than the transfer, and the destination this rule exists for is reported either way. The pinned test asserts that residual on purpose, so the coverage claim cannot drift wider than the reader.Named limit, also in the docstring: an
nthat is really an option's value in the attached spelling (rsync -T/tmp/n src dst,-en) reads as a read form and the destination is missed. Telling the two apart needs the per-option grammar this walk refuses to grow, and the two ways of being wrong are not equally costly — guessing "write" would refusersync -an, the spelling people type to check what a copy would do. The spaced spelling (-T /tmp/n) is unaffected, because a value in its own token is never scanned, and the test pins both halves of that.The stale comment above
_OPTION_DESTINATION_VERBSlistedrsyncamong the families that table does not cover; it now says where rsync went, so no copy of the coverage claim is left narrower than the reader.tests/test_bash_tool_rsync_destination.py(new, 34 tests): every writing form names exactly the destination and is refused at both tiers (including the protected daemon file, with each tier's own reason asserted —read-onlynames the target,workspace-writesays "protected", which is what shows the rule reached the protected-path check); every read form names nothing and stays allowed; a not-blind control (the same command with and without the gate, differing by one letter); and the two named limits pinned as limits.Verification
pytest tests/— 3592 passed, 21 skippedpytest tests/test_bash_tool_rsync_destination.py— 34 passed;tests/test_bash_tool_option_destinations.py— 70 passed, 1 skippedpython -c "from emrg.client.app import run_client"— import OK;python -m emrg --help— usage OKelif word == "rsync"→ a name no command has) turns 22 tests red across the two files; restoring it byte-identically (bash_tool.pysha256[:16]5ad5e2e3e38281be) leaves 104 passed / 1 skipped.scripts/check-doc-count.py,check-rant-citations.py,check-node-test-count.py— all OKMerge order with #1418 (measured, not assumed)
Both this PR and the compressor fix add a rule to
emrg/tools/bash_tool.py. The first push of this branch inserted its block at the same anchor the compressor fix uses, andscripts/check-merge-pairs.py 1418 1419measured 2 of 2 ordered pairs blocked by a conflict — merging either PR would have dirtied the other, costing a resolution push that voids votes.Fixed rather than left to the next cycle: a second commit moves the rule and its helper to sites the compressor branch does not write. The move is provably content-free — 64 lines removed, 64 added, and the two multisets are identical — and after it:
So the two can land in either order, each leaving the other clean.