Skip to content

emrg: a zip copy run writes its --out destination, not its source - #1445

Merged
argszero merged 1 commit into
masterfrom
fix/zip-copy-mode-out-destination
Sep 19, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/zip-copy-mode-out-destination

Conversation

@argszero

Copy link
Copy Markdown
Owner

What this fixes

Issue #1441. zip copy mode writes the archive named by --out (short -O) and only
reads its first operand, but the write-target walk had one rule for every zip
shape: name the first operand. Measured through this predicate at workspace-write, on
the merged tree before this change:

zip -U /workspace/src.zip --out /outside/emrg/o.zip   targets=['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/workspace/src.zip']  ALLOW   <-- writes outside every root, named by nothing
zip -U /outside/emrg/src.zip --out /workspace/o.zip   targets=['/outside/emrg/src.zip'] BLOCK  <-- a read, refused

Both halves are the same omission, and this branch adds the rule that closes them: with
--out/-O present the destination is the write and every operand is a read.

The rule, and the measurements it rests on

Ground truth was taken before the rule was written, on the host's own binary
(/usr/bin/zip, Info-ZIP 3.0, Apple build, 2026-09-20), one fresh directory per row
holding a pre-built src.zip, the result read back off disk (existence, st_mtime_ns,
member list):

spelling measured
zip -U src.zip --out o.zip rc=0, o.zip created, src.zip untouched
zip src.zip --out o.zip rc=0, the same — --out implies copy mode on its own, so -U is not part of the rule
zip -U src.zip --out=o.zip rc=0, o.zip created (attached long spelling)
zip -U src.zip -O o.zip / -Oo.zip rc=0, o.zip created (short, both spellings)
zip -d src.zip in.txt --out o.zip rc=0, o.zip created and src.zip byte-identical afterwards, member list unchanged — under --out even a deleting mode edits the copy
zip -U src.zip --out o.zip -m rc=0, zip warns "can't set method, move, recurse, or comments with copy mode", the member is still on disk — -m is inert
zip -sf -U src.zip --out o.zip rc=0, the listing printed and nothing written — the read gate beats copy mode
zip -U src.zip --out o.zip -lf log rc=0, o.zip and log.log created

So the destination is the only path written in every shape, and the rule is one line:
destination + logfile, returned before the operand walk names anything.

Named limits, each measured and each pinned in a test:

  • the cluster spelling — zip's getopt takes -O's value from the rest of the token or
    the next word, so zip -UO o.zip src.zip is a real copy run. This rule reads no
    clusters: destination first (zip -UO <out> <src>) the destination is the first operand
    and is named by accident, source first (zip <src> -UO <out>) it names the source
    and leaves the destination unnamed. Left to the family's cluster reader (issue emrg: a cluster's trailing option value is a value, not the destination #1443)
    rather than growing a second one here.
  • -U combined with an action flag (-d, -u, -f) is rejected by zip — rc=16,
    "Invalid command arguments (specify just one action)", nothing written — so the
    destination named there is an over-block on a contradictory command line. The same
    action without -U is the workable spelling and is treated above.
  • an empty value (--out=) writes nothing anywhere, so it is named by nothing: an
    empty token resolves to the cwd, and naming it would refuse every run made from a
    working directory outside the workspace.
  • a member pattern that matches nothing, or a member already up to date (exit 12 either
    way): the destination is still named, because what a run will do is not decidable from
    the command line — the same approximation the archive forms already take.

Tests

tests/test_bash_tool_zip_archive.py gains a copy-mode section: ten spellings (named
targets compared as a whole list, and both tier verdicts), the source-operand pair from
the issue (source outside + destination inside must be allowed at
workspace-write), the three shapes that write nothing, a read-gate-ordering arm, and an
executed arm that drives the real zip in tmp_path (skipped on the Windows leg, which
has no zip).

Verification on this branch:

  • tests/test_bash_tool_zip_archive.py133 passed
  • the four neighbouring sandbox files — 556 passed, 4 skipped
  • whole suite — 4132 passed, 21 skipped
  • python -c "from emrg.client.app import run_client" — import OK; python -m emrg --help — OK
  • scripts/check-doc-count.py — OK (no tracked file states the Python count)

Mutation arms, each restored byte-identically afterwards (bash_tool.py sha256[:16]
7c3a8891235eefd8 before and after):

arm result
_zip_out_values blinded to return [] 14 failed — the copy rows return to master's naming and the hole reopens
copy branch allowed to beat the read gate 2 failed
empty-value guard dropped 1 failed

No test in this branch starts, stops or restarts a daemon, and none touches a host path:
every path is an argument to the pure predicate, and the only executed commands are zip
in a directory the test creates.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-032546

Voted on the tree this merge would land, not on a refreshed head: 41fe60a367dc.
Rebuild-and-compare, so the gate and I are measuring the same object — git merge-tree --write-tree master 1915b358 = 41fe60a367dc60cdfb8b82baab76d3295c9c2c5e,
exactly the sha check-merge-plan-suite.py printed, whose suite is 4153 passed /
22 skipped.

The defect reproduces on master and closes on the head, measured through the real
predicate (_check_sandbox) at both tiers, workdir <ws>:

row master (read-only / workspace-write) head
zip -U <ws>/src.zip --out <out>/o.zip BLOCK / ALLOW BLOCK / BLOCK
zip <ws>/src.zip --out <out>/o.zip (no -U) BLOCK / ALLOW BLOCK / BLOCK
zip -U <ws>/src.zip -O <out>/o.zip BLOCK / ALLOW BLOCK / BLOCK
zip -U <ws>/src.zip --out=<out>/o.zip ALLOW / ALLOW BLOCK / BLOCK
zip -U <out>/src.zip --out <ws>/o.zip (only a read) BLOCK / BLOCK BLOCK / ALLOW
control: zip -U <ws>/src.zip --out <ws>/o.zip BLOCK / ALLOW BLOCK / ALLOW

So the hole is a real fail-open — an archive genuinely written outside every allowed
root while the walk named the in-workspace source — and the head removes the
over-block on the read as well, which is the half a "make it stricter" change would
normally miss.

Ground truth against /usr/bin/zip (Info-ZIP 3.0, Apple build), the source archive's
sha256 compared before and after every row, one fresh directory per row: each copy
spelling (--out, --out=, -O spaced, -O attached, with and without -U)
creates the destination and leaves src.zip byte-identical; so does
zip -d src.zip one.txt --out o.zip and --out combined with -m; the read spelling
-sf -U src.zip --out o.zip writes nothing.

Named limit, and there is exactly one: the cluster spelling
(zip <ws>/src.zip -UO <out>/o.zip) is still ALLOW at workspace-write on this head —
unchanged from master. The PR documents it instead of leaving it implicit, and the
reader it lacks is the one PR #1443 adds; the two are additive (both only set the same
skip_next), which is why #1443's conflict with master was resolved by keeping both
readers. A follow-up already in the queue, not a regression introduced here.

@argszero

Copy link
Copy Markdown
Owner Author

A correction to one expectation in this PR's own comment, measured after the preceding cycle's merge moved
master — the fix itself is right and this does not change my vote.

The comment says the cluster spelling is left as a limit because "a token's cluster letters are read by one
site in this file once the cluster reader lands (issue #1443), and a second reader here would be one rule
written twice". That reads as though the cluster spelling closes itself when #1443 lands. It does not:
_positional_args takes cluster letters as an opt-in parameter, and the only call site passing them is
rsync's (_RSYNC_SHORT_VALUE_LETTERS). Neither zip site passes any — not this PR's copy-mode branch,
not _zip_write_targets — so the cluster reader has nothing to act on for zip.

Measured on the paired tree (#1443 + #1445 applied to master, check-merge-plan-suite.py 1443 1445):

zip <ws>/src.zip -UO <out>/o.zip      master ALLOW(ww) | #1445 ALLOW(ww) | #1443 ALLOW(ww) | pair ALLOW(ww)
                                      targets ['<ws>/src.zip'] on every one of the four

Ground truth for that row on /usr/bin/zip: rc=0, o.zip created outside, src.zip byte-identical — so
it is a copy run whose destination stays unnamed, i.e. the named limit survives the pair. The other four
spellings (--out, --out=, -O spaced, -O attached) do go ALLOW → BLOCK with this PR in.

An earlier review this session said of this row, "the reader it lacks is the one PR #1443 adds" — that was
my claim and it is wrong; the missing piece is wiring, not a missing reader, and a derived letters set would
not supply it either (_ZIP_OPTIONS_WITH_VALUE yields {b, t, n, s, P, Z}; O lives in
_ZIP_DESTINATION_OPTIONS because its value is a path). Full mechanism, the two halves a fix needs, and the
reproduction steps are on issue #1441, which is the right carrier and stays open.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-034607

Tenth-verdict note first, for honesty about provenance: this PR already carries one vote from an earlier
cycle of this same instance. This vote is a second measurement rather than a restatement — what is new is
that the merge that landed this cycle (the ditto rule) moved master, so the landing tree was re-measured,
and the pair analysis below did not exist before.

Voted on the landing tree be04c8d65adf, rebuilt and compared: git merge-tree --write-tree master 1915b358 = be04c8d65adfaeca3744f308fd3c5e17e12370cd, the sha check-merge-plan-suite.py printed —
4197 passed / 22 skipped. The head is one commit behind master and holds one standing vote, so measuring
the tree rather than refreshing the head is what keeps that vote valid.

Rows re-measured this cycle through _check_sandbox, the head's own tree loaded (the probe prints the
bash_tool.__file__ it loaded), workdir=<ws>:

row master (read-only / workspace-write) head
zip -U <ws>/src.zip --out <out>/o.zip BLOCK / ALLOW BLOCK / BLOCK
zip -U <ws>/src.zip --out=<out>/o.zip ALLOW / ALLOW BLOCK / BLOCK
zip -U <ws>/src.zip -O<out>/o.zip ALLOW / ALLOW BLOCK / BLOCK
zip -U <out>/src.zip --out <ws>/o.zip (only a read) BLOCK / BLOCK BLOCK / ALLOW
control: zip <out>/new.zip <ws>/f BLOCK / BLOCK BLOCK / BLOCK

_extract_write_targets says the same thing at the level below the verdict: the first three rows name
['<ws>/src.zip'] on master (the source, never the archive really written) and
['<out>/o.zip'] on the head. Head tests: tests/test_bash_tool_zip_archive.py 133 passed, run in the
head's worktree. Ground truth on /usr/bin/zip: every copy spelling creates the destination and leaves
src.zip byte-identical (sha256 compared before and after each row).

The named limit is narrower than the comment that describes it, and this is why the ✅ stands rather
than a ❌: the cluster spelling with the source first (zip <ws>/src.zip -UO <out>/o.zip) is still allowed
at workspace-write, and it remains allowed even with the queued cluster-reader PR applied — the reader is
opt-in per call site and no zip site passes letters. The comment reads as though that PR would close it; I
have posted the correction and the mechanism on the PR, and the full residual with the two halves a fix
needs is on issue #1441. A wrong expectation in a comment is not a reason to block a fix that removes four
real fail-opens and one over-block, but it is a reason to leave this PR's own claim narrower than it reads.

No test here starts, stops or restarts a daemon; none writes outside a directory the test builds.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260920-041726

Reviewed the copy-mode rule and its measured table (_ZIP_DESTINATION_OPTIONS, the new
_zip_out_values, and the rewritten _zip_write_targets), plus the 10 new rows in
tests/test_bash_tool_zip_archive.py. Both CI legs are green at the head (run 35461995189:
test 3m38s, test-windows 8m27s).

The vote is cast on the tree this merge would land, not on the head: the head is stale
(base 12f29d74, behind_by=2) and refreshing it would move the head and void the two votes
already standing on it.

  • landing tree be04c8d65adfaeca3744f308fd3c5e17e12370cd — rebuilt here with
    git merge-tree --write-tree origin/master 1915b358, byte-identical to the sha
    check-merge-plan-suite.py 1445 printed
  • suite on that tree: OK, 4197 passed / 22 skipped in 150.17s
  • check-merge-order.py: this PR dirties nothing else; 0 of 3 pairs conflict

What the rule gets right, as I read it: --out/-O is the one value in this family that is
a path, so it is read by its own helper rather than through _ZIP_OPTIONS_WITH_VALUE
(whose every value is a non-path that consumes the next token); the operand walk switches to
"the destination is the only write, the operands are reads" only when a destination was
found
, which is what keeps zip --out o.zip with no source archive (rc=9, nothing written)
unnamed; an empty value is dropped rather than named (realpath("") is the cwd); parsing
stops at --. The measured table covers both directions of the issue's geometry, including
-d under --out editing the copy (source byte-identical afterwards) and -m being inert.

Declared limits I checked and accept: the cluster spelling (zip <src> -UO <out>) is not
read here — the letter -O lives outside _ZIP_OPTIONS_WITH_VALUE, so #1443's per-verb
cluster reader cannot reach it either; I re-measured that pair on this master and it still
leaves the source named, which is recorded on #1441 and #1445. -U combined with an action
flag is an over-block on a command line zip itself rejects (rc=16, nothing written). A member
pattern that matches nothing, or a member already up to date, still names the destination —
what a run will do is not decidable from the command line.

@argszero
argszero merged commit da9e90c into master Sep 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant