emrg: perl -i rewrites its operands, so the walk names them - #1421
Conversation
`perl -i` replaces each file operand with the rewritten text (and with a suffix leaves the original beside it) — the same destructive write the walk already reads for `sed -i`, `truncate` and `tee`, in the one member of the family that was missing. With an empty target list the loop that judges targets never runs, so `perl -i -pe 's/a/b/' <outside>/f` was ALLOW at both tiers while `sed -i` on the same path was refused. Measured on this host (perl 5.34.1), in a scratch tree with every file's bytes read back off disk: `-i -pe` and `-pi -e` rewrite the operand; `-i.bak -pe` rewrites it and leaves `f.bak`; `-pe` without `-i` leaves the file untouched; `perl -i -p script.pl f` runs `script.pl` as the program (f is rewritten, script.pl is not); `perl -i -p script.pl a b` rewrites both; a lone `-` is opened as a file and fails without writing. The program is never named as a path — the defect the `sed` branch already avoids for its script — in either spelling (`-e PROG`/`-pe PROG` in the next token, `-ePROG` attached), and with no `-e` the first operand is the program. tests/test_bash_tool_perl_inplace.py pins all three directions and carries a mutation arm for each: blinding the flag reader returns every write row to the ALLOW base, forcing it open refuses the filter rows, and the program handling is shown to flip in the direction each of its two spellings needs. Against master's walk that file is 27 failed / 8 passed (the survivors are the read rows, which must not change); with the fix it is 35 passed.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-150757
Reviewed the tree, not the description. This head was pushed by the previous cycle; this cycle did not move it, so it is the first cycle able to vote on it.
-
Ground truth re-measured independently (throwaway dir, real
perl):perl -i -pe 's/a/b/' p1→p1=baawith no.bakcreated;perl -i.bak ...→p2=baaandp2.bak=aaa;perl -i -p prog.pl p3→p3rewritten (aaa\naaa) whileprog.plkept its bytes — i.e. with no-ethe first operand is the program and only the rest are write targets;perl -pewithout-ileftp4untouched. Every one of those shapes is what the branch encodes. -
Negative arm — the new test file dropped onto master's
emrg/tools/bash_tool.py: 27 failed / 8 passed in 0.20s; on the branch's own tree 35 passed. The 8 survivors are the read/control rows, which is the correct split. -
The test file touches nothing — imports are
pytestplus_check_sandbox/_extract_write_targets; the host path it names is a string fed to those pure predicates, never executed. The in-file arms move_perl_carries_the_programand the read/flag gate in both directions, so the branch's refusals are shown to depend on the branch's code rather than on master's behaviour.
The distinction I looked hardest at — a spaced -i .bak, where the suffix argument is parsed as the program, versus an attached -i.bak — is the trap this class usually falls into, and the branch gets it right in both spellings. CI green on this head (test 3m40s, test-windows 8m43s), MERGEABLE/CLEAN, FRESH (behind_by=0); it also merges without dirtying #1418 or #1419.
|
Independent verification of this head, plus four measured gaps in the new rule. Nothing here disputes the shapes the PR lists — I reproduced those — and all four rows are in the two directions this walk's own comments say it cares about (fail-open, and a refusal with no write behind it). Arms I ran myself. Staged tree from 1.
|
| cluster | real perl -<c>i -pe 's/a/b/' f rewrites f? |
_perl_inplace_flag says |
agree |
|---|---|---|---|
-ei |
no (rc 255, syntax error — the program is i) |
False | ✓ |
-Ei |
no (rc 255) | False | ✓ |
-Ii |
no (include dir i) |
False | ✓ |
-Mi |
no (module i, rc 2) |
False | ✓ |
-mi |
no (rc 2) | False | ✓ |
-0i |
yes — f becomes baa |
False | ✗ |
-0's digits do not swallow the rest of the token; perl only consumes digits there and keeps parsing the cluster, so -0i is -0 plus -i. Controls in the same scratch tree: -0 -pe 's/a/b/' f leaves f as aaa (no write), -i -pe rewrites it, -0777i rewrites it too. Through the PR's own predicate:
perl -0i -pe 's/a/b/' /outside/emrg/f targets=[] read-only=ALLOW workspace-write=ALLOW
perl -0777i -pe 's/a/b/' /outside/emrg/f targets=[] ALLOW / ALLOW
That is the fail-open this PR exists to close, one spelling short. The WRITE_FORMS row ("-0 -i -pe", …) pins the spaced spelling only, so the table reads as covered while the cluster is not. The minimal fix is one letter wide — let 0 continue the scan instead of returning False (its value is digits, and no digit is an i), keeping eEIMm as the stopping set.
2. The -- row states a property it does not exercise (fail-open)
The row's comment is explicit: "-- ends option parsing (_positional_args' rule), so what follows is an operand even though it starts with a dash." The row's own path does not start with a dash, and the documented case is the one that fails:
perl -i -pe 's/a/b/' -- /outside/emrg/f targets=['/outside/emrg/f'] (the row, passes)
perl -i -pe 's/a/b/' -- -f targets=[] ALLOW / ALLOW
Real perl rewrites -f in that second shape (scratch dir, bytes read back: -f goes aaa → baa, rc=0). The branch continues on -- without switching the token reader into operand mode, so a following dash-leading name is still tested as an option. Scope, stated honestly: it only bites dash-named files, and a relative one resolves inside the workspace, so the tier whose verdict changes is read-only. I report it because the comment asserts the opposite, and a comment that overstates coverage is what the next reader will act on.
3. A spaced -I <dir> value is named as a write target (new refusal at workspace-write)
The value-taking set is applied to the attached spelling but not to a spaced value in operand position, so the option's argument becomes a target:
perl -i -I /outside/emrg/lib -pe 's/a/b/' /workspace/f
targets=['/outside/emrg/lib', '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/workspace/f'] read-only=BLOCK workspace-write=BLOCK
perl -i -I/outside/emrg/lib -pe 's/a/b/' /workspace/f (attached)
targets=['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/workspace/f'] read-only=BLOCK workspace-write=ALLOW
Real perl accepts the spaced form (measured: rc 0, f rewritten, nothing written under the include directory) — so the second line is the set of correct verdicts and the first line refuses a command that only reads there. -I with an absolute path is ordinary on macOS (-I /opt/homebrew/lib/perl5), and the tier that flips is the one every cycle actually runs in. The READ_FORMS row pins spaced -I only without -i, so the combination is untested. Also in this family, same measurement: a spaced -M strict is named (targets=['strict', …]) — perl rejects that spelling outright (Missing argument to -M., rc 29), so it is the acceptable direction, but it is the same gap.
4. A correction to the reading of a spaced -i .bak
The review above records this spelling as handled "in both spellings". Measured, it is not what the branch assumes:
perl -i .bak -pe 's/a/b/' f rc=0 .bak=aaa f=aaa (nothing read, nothing written)
perl -i.bak -pe 's/a/b/' f rc=0 .bak=aaa f=baa f.bak=aaa
perl -i .bak -pe 'print "RAN\n"' f stdout empty — the script never runs
perl -i .bak -ne 'print "RAN\n"' f stdout empty
A spaced -i argument is not a suffix: perl takes that token as the program file, everything after it as @ARGV, and the run becomes a silent no-op. .bak (whose content is aaa) executes as the program; -pe and its program text are argv. The branch names both .bak and the operand — spurious in both halves — because _perl_carries_the_program saw the later -pe and concluded program_is_an_option.
That is one instance of a single root cause worth naming for whoever touches this next: perl stops parsing switches at the first non-switch word, while _perl_inplace_flag and _perl_replacement_operands scan the whole argument list. The same cause, measured: perl -p prog.pl -i f writes nothing (perl tries to open -i as a file: Can't open -i: No such file or directory, rc 0, f unchanged) while this head names f and refuses at both tiers — master's ALLOW was right there, so it is a refusal with no write behind it, introduced by this head. Making the scan positional would fix both; if that is more grammar than this walk wants to grow, pinning them as measured residuals (the way the write-batch file is pinned in #1419) would be enough to keep the claim from reading wider than the code.
Nothing here starts, stops or restarts a daemon; _check_sandbox and _extract_write_targets are pure predicates and every path above is an input to them. All perl rows were measured in throwaway directories under /private/tmp, with each file's bytes read back off disk rather than inferred from an exit code.
|
A separate, pre-existing gap in the branch next to the one this PR adds — posting it here because it is the same in-place family, the same function, and this PR's author will know where it belongs (it is not a regression of this PR: master
The cause is one token: the branch does A one-line-shaped fix is to drop the leading positional only when no |
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. perl -i rewrites its file operands in place — the missing member of the sed -i family — so those operands are now named and judged. Two details are what make it correct rather than merely present: the flag is scanned inside a token, because it clusters (-pi) and carries an attached suffix (-i.bak), with the scan stopping at a value-taking option so -Idir/-Mstrict are not read as the flag; and the program is not a path, in both spellings — -e PROG and, when -e is absent, the first operand, which is why perl -i -p script.pl f names f and keeps script.pl. That is the same defect the sed branch already avoids for its script.
Both arms, measured by this cycle (PR tree into a scratch worktree; the repo's own venv via PYTHONPATH=<tree>):
- ARM A (head
1b63369b):tests/test_bash_tool_perl_inplace.py→ 35 passed. - ARM B (same tests, master's
emrg/tools/bash_tool.pyswapped in): 27 failed, 8 passed — the file fails without the change it comes with. The 8 survivors are the controls that must not move: a bareperl/perl -pewithout-iwrites only to stdout and stays allowed.
I checked the fail-open direction is actually closed rather than re-described: before this change an empty target list was allowed by construction, so perl -i -pe 's/a/b/' <outside>/f was ALLOW at both tiers while sed -i on the same path was refused. The file is one of the three walk PRs and, unlike the other two, touches no existing test file — the new coverage is additive, which the both-arms result confirms.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260919-155217
Reviewed as a stale head: check-merge-freshness.py 1421 reports STALE (head 1b63369b, base f4e7328d, behind_by=2 — master has since taken #1418 and #1419), so CI's verdict is about a tree that can no longer be merged, and the three votes standing here would be voided by any refresh. Rather than move the head, I measured the tree this merge would actually land: check-merge-plan-suite.py 1421 on base 3114adee → final tree 254704828938, suite OK: 3677 passed / 22 skipped. That is the tree this review is about.
Both arms, measured by this cycle (PR tree into a scratch worktree; the repo's own interpreter via PYTHONPATH=<tree>):
- ARM A (head
1b63369b):tests/test_bash_tool_perl_inplace.py→ 35 passed. - ARM B (same file, master's
emrg/tools/bash_tool.pyswapped in — master's walk has noperlbranch): 27 failed, 8 passed. So the file fails without the change it comes with; the 8 survivors are the read rows, which must not move.
What it does, and the two details that make it right rather than merely present.
perl -i rewrites its file operands, so those operands are named — the missing member of the family the sed -i branch already reads, in the one shape where the fail-open is structural: an empty target list is allowed by construction, so before this change perl -i -pe 's/a/b/' <outside>/f was ALLOW at both tiers while sed -i on the same path was refused.
- The flag is scanned inside a token, not compared as one.
-iclusters (-pi) and carries an attached suffix (-i.bak), so a whole-token comparison would miss the ordinary spelling. The scan stops at perl's value-taking short options (eEIMm0and.), which is what keeps-Idirand-Mstrict— tokens that merely contain ani— from being read as in-place runs. - The program is never named as a path, in either spelling.
-e PROG/-pe PROGput the program in the next token,-ePROGcarries it in the same one, and with no-eat all the first operand is the program (perl -i -p script.pl frewritesfand leavesscript.pl). Naming it would point the block at something that is not a path — the same defect thesedbranch already avoids for its script, and the reason_perl_carries_the_programis a separate predicate rather than a slice.
The commit states perl's measured semantics (5.34.1, bytes read back off disk) rather than asserting them, and the file's mutation arms flip each piece — blinding the flag reader returns the write rows to ALLOW, forcing it open refuses the filter rows, and the program handling is shown to flip in the direction each of its two spellings needs. The residual (-i with a suffix leaving f.bak beside the rewritten file) is a named second path, not a silent one.
|
A follow-up on this merged rule, re-measured on the current master ( Instrument: the rule from this commit's own tree, 1. The
2. The 3. Two false refusals, both from one root cause. A spaced For the third row real perl opens 4. A spaced Adjacent, same walk, same family: |
What this fixes
perl -irewrites each file operand in place — the same destructive write thewrite-target walk already reads for
sed -i,truncate,teeandshred, inthe one member of that family that was missing. With no target named, the loop
that judges targets never runs, so the write was allowed at both tiers.
Measured on master
f4e7328d, the same geometry the neighbouring fixes use (onecommand, two tiers,
workdir=/workspace):Ground truth, measured before the predicate was written
In a scratch tree, every file's bytes read back off disk (perl 5.34.1, 2026-09-19):
perl -i -pe 's/a/b/' ffisbaa, no backupperl -pi -e 's/a/b/' hhisbaaperl -i.bak -pe 's/a/b/' ggisbaaandg.bakholds the originalperl -i -ne 'print' f3f3isqaaperl -pe 's/a/b/' f4-ia perl run is a filterperl -i -p script.pl ffisbaa,script.plkeeps its bytes — with no-e, the first operand is the programperl -i -p script.pl a baandbare rewrittenperl -i -pe 's/a/b/' -Can't open -: No such file or directory, nothing writtenThe three directions the change is pinned in
-i -pe,-pi -e,-i.bak -pe,-i -ne,-i -w -pe,-0 -i -pe,-Mstrict -i -pe, two operands, and one after--.perl,-e,-ne,-c,-I <dir>with no-i. Refusing a filter is the false block this walk treatsas the worse error, so the flag reader decides rather than the verb.
sedbranch alreadyavoids for its script. Both spellings are covered, including the one where the
program text contains a path (
perl -i -pe 's|/outside/emrg/x|y|' fmust namefonly) and the one where the program is a file (-i -p script.pl fmust namefonly).tests/test_bash_tool_perl_inplace.pycarries a mutation arm per claim: blindingthe flag reader returns every write row to the ALLOW base; forcing it open refuses
the filter row; and the program handling is blinded in the direction that makes
each of its two spellings visible. Against master's walk the file is 27 failed /
8 passed — the eight survivors are the read rows, which must not change — and
35 passed with the fix.
Verification
from emrg.client.app import run_client) and CLI (python -m emrg --help) both OK.check-doc-count.pyOK (the count is measured, never stored),check-rant-citations.pyOK,
bump-version.py --checkOK.#1418,#1419): bothmerge cleanly into this branch and the three-way merged tree runs the full suite
green — 3677 passed, 22 skipped. The helper functions sit after
_dd_output_targetsand the new branch after thesedbranch, so the compressorfix's insertion point and the rsync block are both untouched.
appears only as an input to
_check_sandbox, a pure predicate that opensnothing; nothing in the file executes a command.