From db2e8c658ee8304dd980bf2541ddb413f6229191 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 22:55:50 +0800 Subject: [PATCH 1/6] emrg: zip writes its first operand, so the walk names the archive --- emrg/tools/bash_tool.py | 101 ++++ tests/test_bash_tool_option_destinations.py | 18 +- tests/test_bash_tool_zip_archive.py | 493 ++++++++++++++++++++ 3 files changed, 603 insertions(+), 9 deletions(-) create mode 100644 tests/test_bash_tool_zip_archive.py diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 01da0ed8..e4f21bb0 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -264,6 +264,66 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: if opt.startswith("-") and not opt.startswith("--") ) +# `zip` writes the archive, and the archive is the **first** operand — the +# opposite end of the operand list from `cp`/`mv`/`rsync`, whose destination is +# the last one. Every operand rule the walk already has reads the last operand or +# every operand, so none of them reaches it and the archive was named by nothing. +# +# Measured on the host's own binary (`/usr/bin/zip`, Info-ZIP 3.0, 2026-09-19): +# one fresh directory per row holding `f` and `g`, `a.zip` pre-built where the row +# needs one, and the result read back off disk as `st_mtime_ns` plus a content +# hash (the hash alone cannot see an in-place rewrite of identical bytes, which is +# exactly what `zip a.zip f` does when `f` is unchanged) — +# +# zip a.zip f a.zip CREATED (no archive yet) +# zip a.zip f a.zip REWRITTEN (archive exists) +# zip -q -r a.zip . a.zip CREATED +# zip -m a.zip f g a.zip CREATED, f AND g GONE +# zip --move a.zip f a.zip CREATED, f GONE +# zip -d a.zip f a.zip REWRITTEN (entry deleted) +# zip -u a.zip g a.zip REWRITTEN +# zip -o a.zip f a.zip REWRITTEN +# zip -T a.zip f a.zip REWRITTEN mtime moved +# zip -T a.zip read "test of a.zip OK" mtime untouched +# zip -sf a.zip [f] read "Would Add/Update:" mtime untouched +# zip --show-files a.zip f read same line mtime untouched +# zip -su a.zip / -sU a.zip read rc=16, nothing written +# zip -h a.zip f read help, nothing written +# zip -h2 a.zip f read extended help, nothing written +# zip -L a.zip f read licence, nothing written +# zip --help a.zip f read help, nothing written +# zip --version a.zip f read help, nothing written +# zip a.zip nothing rc=12 "Nothing to do!" +# zip -d a.zip nothing rc=12 +# zip -v a.zip nothing rc=12 +# zip -l a.zip f a.zip CREATED lowercase `-l` is LF->CRLF +# zip -v a.zip f a.zip REWRITTEN uppercase `-v` is verbose +# zip -m a.zip f -x f nothing rc=12 the exclusion won +# +# Four consequences, all measured rather than read off the usage line: +# +# 1. A run with **no list** writes nothing, whatever the mode: `zip a.zip`, +# `zip -d a.zip` and `zip -v a.zip` each exit 12 with "Nothing to do!". So the +# archive is named only when a second operand follows it, and `-T` needs no +# rule of its own — `zip -T a.zip` is the *test* form and its one-operand shape +# is already the "nothing written" case. +# 2. `-T` is therefore **not** a read. With a list it rewrites the archive +# (`zip -T a.zip f`, mtime moved), which is the lz4 `-l` lesson again: a +# spelling that is a read in one shape and a write in another cannot be read +# as a flag. +# 3. `-m`/`--move` **deletes** every listed file once it is archived, so under it +# the operands after the archive are write targets too, not inputs. +# 4. The read spellings are matched as **whole tokens, case-sensitively**: `-sf` +# is show-files while `-f` is freshen (a write), `-L` is the licence while `-l` +# is the LF->CRLF conversion (a write, measured above). A letter scan — the +# shape the compressor family uses — would conflate both pairs. +_ZIP_OPTIONS_WITH_VALUE = frozenset({"-b", "-t", "-n", "-s", "-TT"}) +_ZIP_READ_TOKENS = frozenset({ + "-sf", "-su", "-sU", "-h", "-h2", "-L", "--help", "--version", + "--show-files", +}) +_ZIP_MOVE_FLAGS = frozenset({"-m", "--move"}) + # Verbs that *create* every path named by an operand (`touch a b c`, # `mkdir -p a/b`). They were invisible to the write-target walk (issue #1398): # with no target named, the loop that judges targets never ran, so both checked @@ -1781,6 +1841,13 @@ def is_separator(index: int) -> bool: # `lz4 -m f g` writes two siblings, and `lz4 f out.lz4` writes the # last operand — unless the run is one of the measured read forms. targets.extend(_lz4_write_targets(tokens, i)) + elif word == "zip": + # `zip A.zip f` creates or rewrites `A.zip`, and the archive is the + # *first* operand — the end no other operand rule reads, so the run + # named nothing at all and both tiers allowed it (issue #1420's + # remaining row). The rule, its measured table and its two named + # limits are in `_zip_write_targets`. + targets.extend(_zip_write_targets(tokens, i)) elif word == "sed": # `sed -i` rewrites its file operands in place; a bare `sed` is a # filter that writes only to stdout and must stay allowed. The flag @@ -2030,6 +2097,40 @@ def _lz4_write_targets(tokens: list[str], i: int) -> list[str]: return operands[-1:] +def _zip_write_targets(tokens: list[str], i: int) -> list[str]: + """The paths a ``zip`` run writes: its **first** operand, and what it moves. + + The measured table is the comment above `_ZIP_OPTIONS_WITH_VALUE`; the rule it + settles is three lines long, and each line is one of its rows: + + * a read spelling (``-sf``/``--show-files``, ``-su``/``-sU``, the help and + licence forms) writes nothing, so nothing is named; + * with no second operand the run writes nothing at all (exit 12, "Nothing to + do!"), which is what keeps `zip a.zip` and `zip -d a.zip` allowed; + * otherwise the archive — the *first* operand — is the path that is created + or rewritten, and under ``-m``/``--move`` every listed operand after it is + removed as well. + + Named limit: the exclusion list (``-x``) and the include list (``-i``) are + matched against the operands **by name**, and a name they neutralise is still + named here. Measured, `zip -m a.zip f -x f` writes nothing, so the over-block + lands on a run that does nothing anyway; the alternative is a per-name match + in the walk, the grammar this family of rules refuses to grow (see + `_rsync_run_is_a_read` for the same trade taken the other way). `-@` reads its + names from stdin, which the walk cannot see: that spelling stays unnamed. + """ + words = _args_after_command(tokens, i) + if any(tok in _ZIP_READ_TOKENS for tok in words): + return [] + operands = _positional_args(tokens, i, _ZIP_OPTIONS_WITH_VALUE) + if len(operands) < 2: + # Archive and no list: zip exits 12 having written nothing. + return [] + if any(tok in _ZIP_MOVE_FLAGS for tok in words): + return operands + return operands[:1] + + def _is_directory_install(tokens: list[str], i: int) -> bool: """True when an ``install`` invocation creates directories (``-d``). diff --git a/tests/test_bash_tool_option_destinations.py b/tests/test_bash_tool_option_destinations.py index b9d6bb12..21a4d9f2 100644 --- a/tests/test_bash_tool_option_destinations.py +++ b/tests/test_bash_tool_option_destinations.py @@ -239,20 +239,20 @@ def test_the_cluster_spelling_is_a_measured_residual_not_a_guess(): # rsync made. It now has its own rule (`_SPLIT_OPTIONS_WITH_VALUE`) and its own file — # `tests/test_bash_tool_split_prefix.py`. # -# `zip` takes the vacated row, and it is here for the *other* stated reason rather -# than a new one: its archive is readable from the first operand — no flag grammar -# needed to *place* it — but that same operand is a **read** under `-T`/`-sf`/`-L`/`-h` -# and a write otherwise, which is `tar`'s shape. Measured on this host in a scratch -# directory: `zip a.zip in.txt` created `a.zip` (`zip foo in.txt` created `foo.zip`, -# the suffix appended), while `zip -T a.zip` and `zip -sf a.zip` left the archive's -# mtime unchanged. So covering `zip` means a read gate, and the gate is why the row is -# pinned as a hole instead of guessed at. +# `zip` was pinned here with the reason above and has left it, the third row to do +# so: its archive is readable from the first *operand* — no flag grammar needed to +# place it — and the read half is a spelling (`-sf`/`--show-files` for show-files, +# `-T` only while it has no list) rather than the "value of an option". Its own rule +# is `_zip_write_targets`, its measured table is the comment above +# `_ZIP_OPTIONS_WITH_VALUE`, and its rows live in +# `tests/test_bash_tool_zip_archive.py`. The departure was measured while moving it: +# with the rule in place this row's own assertion reds (`zip OUT/a.zip x` now names +# `/outside/emrg/a.zip`), which is the signal this table is written to give. UNCOVERED_WRITERS = ( # (row, command, allowed under read-only, allowed under workspace-write) ("tar -cf", "tar -cf OUT/a.tgz x", True, True), ("tar -xf -C spaced", "tar -xf a.tgz -C OUT", True, True), ("tar -xf -C attached", "tar -xf a.tgz -COUT", True, True), - ("zip", "zip OUT/a.zip x", True, True), ("csplit", "csplit x /re/ -f OUT/pre", True, True), ("curl -so cluster", "curl -soOUT/f https://example.invalid/x", True, True), ("git clone", "git clone https://example.invalid/r.git OUT/clone", False, True), diff --git a/tests/test_bash_tool_zip_archive.py b/tests/test_bash_tool_zip_archive.py new file mode 100644 index 00000000..2042f9b9 --- /dev/null +++ b/tests/test_bash_tool_zip_archive.py @@ -0,0 +1,493 @@ +"""`zip` writes the archive named by its **first** operand, so the walk names it. + +`zip` was the last row of `test_bash_tool_option_destinations.py`'s +`UNCOVERED_WRITERS` table, and it left that table the same way `rsync` and `split` +did: its destination is an **operand** rather than an option's value, so the +question is which operand is written, not the per-verb flag grammar that table is +about. It differs from both of those in one respect — the write is at the *first* +operand, and every operand rule the walk already had reads the last one or all of +them, so nothing reached it. + +Measured on master `26449c59` with the real predicate, the archive outside every +allowed root: `zip /a.zip x` reported an **empty target list**, and an +empty list is allowed by construction — the loop that judges targets never runs. +`cp` on the same path was refused in the same geometry, which is what makes this a +hole rather than an opinion. The same file run against the fixed tree names 24 of +its 37 rows where master names 0. + +Ground truth for what `zip` really does was taken before the rule was written, on +the host's own binary (`/usr/bin/zip`, Info-ZIP 3.0, 2026-09-19): one fresh +directory per row holding `f` and `g`, `a.zip` pre-built where the row needs one, +and the result read back off disk as `st_mtime_ns` **plus** a content hash. The hash +alone is not enough — `zip a.zip f` on an archive that already holds `f` writes +identical bytes, so only the mtime says the file was rewritten. The full table is +the comment above `_ZIP_OPTIONS_WITH_VALUE`; the four rows this file's arms rest on +are: + +* a run with **no list** writes nothing (`zip a.zip`, `zip -d a.zip`, `zip -v a.zip` + all exit 12 with "Nothing to do!"), so naming the archive there would be a block + on a command that writes nothing; +* `-T` is *not* a read — `zip -T a.zip f` rewrote the archive (mtime moved) while + `zip -T a.zip` tested it and left it alone; +* `-m`/`--move` **deletes** every listed file, so the operands after the archive are + write targets too; +* the read spellings are whole tokens and case matters — `-sf` is show-files while + `-f` is freshen, `-L` is the licence while `-l` is an LF→CRLF conversion that + really creates the archive. + +Nothing here executes a command through the walk: `_check_sandbox` and +`_extract_write_targets` are pure (they `realpath` a path and parse a string), so +the outside and protected paths below are arguments to a predicate. The one arm that +does execute a real `zip` runs it in `tmp_path` — a directory the test creates — and +is skipped on Windows, whose CI leg has no `zip`. +""" + +import os +import shutil +import subprocess +import sys +import time + +import pytest + +from emrg.tools import bash_tool +from emrg.tools.bash_tool import _check_sandbox, _extract_write_targets + +# Outside every allowed root (the workspace, the OS temp root, the evolution data +# dir) and used only as an argument to the pure predicate — never executed. +OUTSIDE = "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/outside/emrg" +WORKSPACE = "/workspace" + +# The daemon's own rant store: a protected file, and the one a sandboxed task must +# not be able to destroy. Also only ever an input to the predicate. +PROTECTED = "~/.emrg/rants.jsonl" + + +def tiers(cmd): + """Both tier verdicts for one command, as the toolbox would answer them.""" + return { + tier: _check_sandbox(cmd, tier, WORKSPACE)[0] + for tier in ("read-only", "workspace-write") + } + + +def test_the_predicate_is_the_one_the_tiers_read(): + """The two functions this file judges are the ones the tool layer calls. + + A test that reached a private re-reading would keep passing while the shipped + guard changed, which is the failure this whole class of test exists to prevent. + """ + allowed, _reason, enforcement = _check_sandbox( + "zip a.zip in.txt", "workspace-write", WORKSPACE + ) + assert allowed is True + assert enforcement in ("partial", "full") + + +# ── writing forms: the archive operand must be named ──────────────────────────────── +# +# (row, command, targets) — the operand files are deliberately *inside* the +# workspace so a rule that named the wrong operand would be caught by the target it +# reports rather than by the verdict it gives. +WRITING_FORMS = ( + ("add", "zip {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("quiet", "zip -q {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("recurse", "zip -r {out}/a.zip {ws}/dir", ("{out}/a.zip",)), + ("delete an entry", "zip -d {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("update", "zip -u {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("freshen", "zip -f {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("stamp from entries", "zip -o {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # `-T` with a list is the update path, not the test path (ground truth, mtime). + ("test with a list", "zip -T {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("verbose with a list", "zip -v {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # Lowercase `-l` is the LF→CRLF conversion, not `-L` the licence. + ("line-ending conversion", "zip -l {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # `-m` deletes what it archived, so those operands are write targets too. + ("move", "zip -m {out}/a.zip {ws}/in.txt", ("{out}/a.zip", "{ws}/in.txt")), + ("move, long spelling", + "zip --move {out}/a.zip {ws}/in.txt", ("{out}/a.zip", "{ws}/in.txt")), + ("move two operands", + "zip -m {out}/a.zip {ws}/a {ws}/b", + ("{out}/a.zip", "{ws}/a", "{ws}/b")), + # Spaced option values must not be read as the archive (the wrong-name defect + # `_positional_args` exists to avoid). + ("spaced -b temporary dir", + "zip -b {ws}/tmp {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -n suffix", + "zip -n .jpg {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -s split size", + "zip -s 64k {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -t date", + "zip -t 20010101 {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -TT command", + "zip -TT 'unzip -tqq' {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # A resolved verb, a chain and a nested shell all reach the same rule. + ("absolute path to the verb", + "/usr/bin/zip {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("second in a chain", + "true && zip {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("inside sh -c", + "sh -c 'zip {out}/a.zip {ws}/in.txt'", ("{out}/a.zip",)), +) + + +def rendered(rows): + """The rows with their two placeholders filled in, as (row, cmd, targets).""" + return [ + (row, cmd.format(out=OUTSIDE, ws=WORKSPACE), + tuple(t.format(out=OUTSIDE, ws=WORKSPACE) for t in targets)) + for row, cmd, targets in rows + ] + + +@pytest.mark.parametrize("row,cmd,targets", rendered(WRITING_FORMS), + ids=[row for row, _c, _t in WRITING_FORMS]) +def test_every_writing_form_names_the_archive(row, cmd, targets): + """The first operand is the archive, and the walk must report exactly it. + + The whole list is asserted, not just the first entry, because the `-m` rows have + more than one target and a rule that named the wrong end of the operand list + would still pass a "something was named" test. + """ + assert _extract_write_targets(cmd) == list(targets), row + + +@pytest.mark.parametrize("row,cmd,targets", rendered(WRITING_FORMS), + ids=[row for row, _c, _t in WRITING_FORMS]) +def test_a_named_archive_is_refused_at_both_tiers(row, cmd, targets): + """An archive outside every allowed root is refused whether or not writes are on. + + Both tiers, because the hole was in both and the untested tier is the one a cycle + actually runs in: `workspace-write` still refuses writes outside the workspace, + and `read-only` refuses them anywhere. + """ + assert targets, row # the row's named targets are the other test's assertion + for tier, allowed in tiers(cmd).items(): + assert allowed is False, f"{row}: {tier} allowed a write to {OUTSIDE}" + + +def test_the_protected_daemon_file_is_refused_at_both_tiers(): + """The strongest case: the archive *is* the file the sandbox protects. + + `zip x` creates or rewrites its archive in place, so the archive here + is the daemon's own rant store and not merely a file beside it — measured while + writing this test, a sibling path (`rants.jsonl.zip`) is refused as "outside + workspace", which says nothing about the protected check. The path is an argument + to the pure predicate — `_check_sandbox` `realpath`s it and opens nothing — so the + host's real store is never touched by this test, and the command is never run. + """ + cmd = f"zip {PROTECTED} {WORKSPACE}/in.txt" + assert _extract_write_targets(cmd) == [PROTECTED] + + allowed, reason, _ = _check_sandbox(cmd, "read-only", WORKSPACE) + assert allowed is False, "read-only allowed a write into the protected store" + assert PROTECTED in (reason or ""), reason + + allowed, reason, _ = _check_sandbox(cmd, "workspace-write", WORKSPACE) + assert allowed is False, "workspace-write allowed a write into the protected store" + assert "protected" in (reason or ""), reason + + +def test_an_archive_inside_the_workspace_is_allowed_where_writes_are_on(): + """The rule places the write; it does not refuse how the tool is normally used. + + Named-and-inside is the ordinary case, and the two tiers read it as they read any + other in-workspace write: `read-only` still refuses it on its own account (that + tier refuses every named target but `/dev/null`), and `workspace-write` allows it. + """ + cmd = f"zip {WORKSPACE}/a.zip {WORKSPACE}/in.txt" + assert _extract_write_targets(cmd) == [f"{WORKSPACE}/a.zip"] + assert tiers(cmd)["read-only"] is False + assert tiers(cmd)["workspace-write"] is True + + +# ── forms that name nothing: naming them would be a false block ───────────────────── +# +# Each row is one of the measured "nothing written" shapes: the read spellings, and +# the runs whose only operand is the archive (zip exits 12 having created nothing). +NAMING_NOTHING = ( + ("-T alone", "zip -T {out}/a.zip"), + ("-sf alone", "zip -sf {out}/a.zip"), + ("-sf with a list", "zip -sf {out}/a.zip {ws}/in.txt"), + ("--show-files with a list", "zip --show-files {out}/a.zip {ws}/in.txt"), + ("-su alone", "zip -su {out}/a.zip"), + ("-sU alone", "zip -sU {out}/a.zip"), + ("-h with a list", "zip -h {out}/a.zip {ws}/in.txt"), + ("-h2 with a list", "zip -h2 {out}/a.zip {ws}/in.txt"), + ("-L with a list", "zip -L {out}/a.zip {ws}/in.txt"), + ("--help with a list", "zip --help {out}/a.zip {ws}/in.txt"), + ("--version with a list", "zip --version {out}/a.zip {ws}/in.txt"), + ("archive and no list", "zip {out}/a.zip"), + ("delete and no members", "zip -d {out}/a.zip"), + ("verbose and no list", "zip -v {out}/a.zip"), +) + + +@pytest.mark.parametrize("row,cmd", NAMING_NOTHING, ids=[row for row, _ in NAMING_NOTHING]) +def test_a_form_that_writes_nothing_names_nothing(row, cmd): + """Nothing in these rows creates or rewrites a file, so no target is reported.""" + assert _extract_write_targets(cmd.format(out=OUTSIDE, ws=WORKSPACE)) == [], row + + +@pytest.mark.parametrize("row,cmd", NAMING_NOTHING, ids=[row for row, _ in NAMING_NOTHING]) +def test_those_forms_stay_allowed(row, cmd): + """The false block is the direction this walk treats as worse than the hole.""" + for tier, allowed in tiers(cmd.format(out=OUTSIDE, ws=WORKSPACE)).items(): + assert allowed is True, f"{row}: {tier} refused a form that writes nothing" + + +# ── the discriminator, in both directions ─────────────────────────────────────────── +def test_the_list_is_what_decides_and_the_two_spellings_are_read_apart(): + """The control: a pair that differs only by the list, and two token pairs. + + Without this, a rule that named nothing at all — or a fixture that never reached + the rule — would leave every test above green while the defect was fully open. + + The token pairs are the case-sensitivity claim, and they are read in the + direction that costs data if it is wrong: `-L` (the licence, a read) versus `-l` + (the LF→CRLF conversion, which really creates the archive), and `-sf` + (show-files) versus `-f` (freshen, a write). A letter scan — the shape the + compressor family uses — would conflate both pairs, so each is asserted unnamed + on the read side **and** named on the write side. + """ + writes = f"zip {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + no_list = f"zip {OUTSIDE}/a.zip" + assert _extract_write_targets(writes) == [f"{OUTSIDE}/a.zip"] + assert _extract_write_targets(no_list) == [] + + read_rows = (f"zip -L {OUTSIDE}/a.zip {WORKSPACE}/in.txt", + f"zip -sf {OUTSIDE}/a.zip {WORKSPACE}/in.txt") + write_rows = (f"zip -l {OUTSIDE}/a.zip {WORKSPACE}/in.txt", + f"zip -f {OUTSIDE}/a.zip {WORKSPACE}/in.txt") + for row in read_rows: + assert _extract_write_targets(row) == [], row + for row in write_rows: + assert _extract_write_targets(row) == [f"{OUTSIDE}/a.zip"], row + + +# ── mutation arms: a row that cannot be flipped is not a claim ────────────────────── +def test_the_rule_is_what_names_the_archive() -> None: + """Blind the rule, and its rows must go back to the ALLOW master gave.""" + cmd = f"zip {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + assert tiers(cmd)["read-only"] is False + original = bash_tool._zip_write_targets + try: + bash_tool._zip_write_targets = lambda tokens, i: [] + allowed = tiers(cmd)["read-only"] + finally: + bash_tool._zip_write_targets = original + assert allowed is True, ( + "the row survives blinding _zip_write_targets — it does not depend on the " + "rule it claims to test" + ) + + +def test_the_read_gate_is_what_spares_the_read_forms() -> None: + """The gate is a second piece of code, so it gets its own arm, both ways. + + One direction widens it (the write row's own token becomes a read) and the write + row must return to ALLOW; the other empties it and the read rows must become + refusals. A rule that named the archive without the gate would pass neither. + + Every row in the closed-gate half carries a **list** on purpose: a read spelling + with no list after it is spared by the no-list rule as well, so it would stay + allowed with the gate shut — measured while writing this arm, `zip -T /a.zip` + does exactly that — and asserting otherwise would be testing the wrong line. + """ + write_row = f"zip -l {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + read_row = f"zip -sf {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + assert tiers(write_row)["read-only"] is False + assert tiers(read_row)["read-only"] is True + + original = bash_tool._ZIP_READ_TOKENS + try: + bash_tool._ZIP_READ_TOKENS = frozenset({"-l"}) + assert tiers(write_row)["read-only"] is True, ( + "with the gate forced wide the write row must return to the ALLOW master " + "gave — otherwise the gate is not what refuses it" + ) + bash_tool._ZIP_READ_TOKENS = frozenset() + for row in (read_row, + f"zip --show-files {OUTSIDE}/a.zip {WORKSPACE}/in.txt", + f"zip -L {OUTSIDE}/a.zip {WORKSPACE}/in.txt", + f"zip -h {OUTSIDE}/a.zip {WORKSPACE}/in.txt"): + assert tiers(row)["read-only"] is False, ( + f"{row}: with the gate forced shut the read form must be refused — " + "otherwise nothing about it depends on the gate" + ) + finally: + bash_tool._ZIP_READ_TOKENS = original + + +def test_the_move_flag_is_what_names_the_listed_operands() -> None: + """`-m` is a third piece of the rule: without it only the archive is a target. + + Read in both directions, because the two are different claims — that the flag + adds the operands, and that *nothing else* adds them. The unflagged row is the + `zip a.zip f` form, whose operand is only read. + """ + moved = f"zip -m {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + archived = f"zip {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + assert f"{WORKSPACE}/in.txt" in _extract_write_targets(moved) + assert f"{WORKSPACE}/in.txt" not in _extract_write_targets(archived) + + +# ── the named limits, pinned so a later reader does not re-derive them ────────────── +def test_the_exclusion_list_is_the_documented_residual(): + """An operand the `-x` list neutralises by name is still named. + + Measured: `zip -m a.zip f -x f` writes nothing (exit 12), so the over-block lands + on a run that does nothing anyway. The alternative is matching operand names + against exclusion patterns inside the walk — the grammar these rules refuse to + grow. Pinned so the coverage claim in `bash_tool.py` stays as wide as the rule. + """ + cmd = f"zip -m {OUTSIDE}/a.zip {WORKSPACE}/in.txt -x {WORKSPACE}/in.txt" + assert _extract_write_targets(cmd) == [ + f"{OUTSIDE}/a.zip", f"{WORKSPACE}/in.txt", f"{WORKSPACE}/in.txt", + ] + + +def test_names_read_from_stdin_are_the_documented_residual(): + """`-@` takes its names from stdin, which the walk cannot see. + + The archive is still named, so the run is judged by where its own write lands; + the names the standard input supplies stay unnamed, which is the same limit every + other stdin-driven form has. + """ + cmd = f"zip -@ {OUTSIDE}/a.zip" + assert _extract_write_targets(cmd) == [] + + +# ── ground truth, executed: the derivation the rule is built on ───────────────────── +needs_zip = pytest.mark.skipif( + sys.platform == "win32" or shutil.which("zip") is None, + reason="no zip on the Windows CI leg", +) + + +def _archive_state(path): + """(mtime_ns, size) of an archive, or None when it does not exist.""" + if not path.exists(): + return None + stat = path.stat() + return (stat.st_mtime_ns, stat.st_size) + + +@needs_zip +def test_zip_really_creates_the_archive_it_is_pointed_at(tmp_path): + """Drive the tool itself, so the rule rests on what it does rather than a manual. + + `tmp_path` is a directory the test creates, so nothing here can reach a host + path; the archive is read back off disk instead of assumed. + """ + (tmp_path / "in.txt").write_text("hello\n") + archive = tmp_path / "a.zip" + result = subprocess.run( + ["zip", "-q", str(archive), str(tmp_path / "in.txt")], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert result.returncode == 0, result.stderr + assert _archive_state(archive) is not None, "the named archive was not created" + assert _extract_write_targets( + f"zip -q {archive} {tmp_path / 'in.txt'}" + ) == [str(archive)] + + +@needs_zip +def test_the_read_forms_really_leave_the_archive_alone(tmp_path): + """The read half, executed: `-sf` and `-T`-without-a-list must not rewrite it. + + mtime is the discriminator rather than the content hash, because `zip -T a.zip f` + on an unchanged operand rewrites *identical bytes* — measured, and the reason the + product rule treats `-T` as a write whenever a list follows it. + """ + (tmp_path / "in.txt").write_text("hello\n") + archive = tmp_path / "a.zip" + subprocess.run(["zip", "-q", str(archive), str(tmp_path / "in.txt")], + capture_output=True) + before = _archive_state(archive) + + for argv in (["zip", "-sf", str(archive)], + ["zip", "-sf", str(archive), str(tmp_path / "in.txt")], + ["zip", "-T", str(archive)]): + time.sleep(0.01) + result = subprocess.run(argv, capture_output=True, text=True, + encoding="utf-8", errors="replace") + assert result.returncode == 0, (argv, result.stdout, result.stderr) + assert _archive_state(archive) == before, ( + f"{' '.join(argv)} rewrote the archive, so the rule names a write " + "as a read" + ) + assert _extract_write_targets(" ".join(argv)) == [] + + # …and the same letter with a list does rewrite it, which is the other direction. + time.sleep(0.01) + result = subprocess.run(["zip", "-T", str(archive), str(tmp_path / "in.txt")], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr + assert _archive_state(archive) != before, ( + "`zip -T a.zip f` did not rewrite the archive on this host, so the rule's " + "write side for it is a claim rather than a measurement" + ) + assert _extract_write_targets( + f"zip -T {archive} {tmp_path / 'in.txt'}" + ) == [str(archive)] + + +@needs_zip +def test_move_really_removes_the_operand_it_archived(tmp_path): + """`-m` is the row that makes the listed operands write targets; executed. + + The operand must be *gone* afterwards — that is the whole claim — and it is a file + the test created inside its own directory, so nothing outside `tmp_path` is at + stake. + """ + operand = tmp_path / "in.txt" + operand.write_text("hello\n") + archive = tmp_path / "a.zip" + result = subprocess.run(["zip", "-m", "-q", str(archive), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr + assert _archive_state(archive) is not None, "the archive was not created" + assert not operand.exists(), "`zip -m` did not remove the operand it archived" + assert _extract_write_targets(f"zip -m {archive} {operand}") == [ + str(archive), str(operand), + ] + + +@needs_zip +def test_an_archive_only_run_really_writes_nothing(tmp_path): + """The no-list row, executed: exit 12 and no archive appears. + + This is what keeps `zip a.zip` out of the target list — naming the archive there + would refuse a run that creates nothing. + """ + archive = tmp_path / "a.zip" + before = _archive_state(archive) + result = subprocess.run(["zip", str(archive)], capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode != 0, "an archive-only run unexpectedly succeeded" + assert _archive_state(archive) == before, ( + "`zip a.zip` created the archive, so the no-list rule is wrong" + ) + assert _extract_write_targets(f"zip {archive}") == [] + + +@needs_zip +def test_the_lowercase_l_really_writes_and_the_uppercase_L_does_not(tmp_path): + """The case pair, executed — the one a letter scan would get wrong.""" + operand = tmp_path / "in.txt" + operand.write_text("hello\n") + + lower = tmp_path / "lower.zip" + result = subprocess.run(["zip", "-l", "-q", str(lower), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr + assert os.path.exists(lower), "lowercase `-l` did not create the archive" + assert _extract_write_targets(f"zip -l {lower} {operand}") == [str(lower)] + + upper = tmp_path / "upper.zip" + result = subprocess.run(["zip", "-L", str(upper), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr + assert not os.path.exists(upper), "uppercase `-L` created the archive" + assert _extract_write_targets(f"zip -L {upper} {operand}") == [] From 35c1b3b237fbfd7b61e21629df48f9924f73ce15 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 22:56:15 +0800 Subject: [PATCH 2/6] emrg: the zip rule records why -b is not a write target --- emrg/tools/bash_tool.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index e4f21bb0..de0e24ad 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -2118,6 +2118,12 @@ def _zip_write_targets(tokens: list[str], i: int) -> list[str]: in the walk, the grammar this family of rules refuses to grow (see `_rsync_run_is_a_read` for the same trade taken the other way). `-@` reads its names from stdin, which the walk cannot see: that spelling stays unnamed. + + ``-b `` (the temporary directory, a spaced value this rule drops) is + deliberately not named, and that is a measurement rather than an omission: + taken in a scratch directory, `zip -b a.zip f` left the directory + **empty** afterwards, and so did a run that failed — the temporary archive is + removed before the process exits, so there is no surviving path to protect. """ words = _args_after_command(tokens, i) if any(tok in _ZIP_READ_TOKENS for tok in words): From a3cb1b6b6676d89d1e04aa457a9a6b7e241ad9a9 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 22:59:21 +0800 Subject: [PATCH 3/6] emrg: the zip row's departure note does not count the rows that leave --- tests/test_bash_tool_option_destinations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_bash_tool_option_destinations.py b/tests/test_bash_tool_option_destinations.py index 21a4d9f2..99fee9f4 100644 --- a/tests/test_bash_tool_option_destinations.py +++ b/tests/test_bash_tool_option_destinations.py @@ -239,8 +239,8 @@ def test_the_cluster_spelling_is_a_measured_residual_not_a_guess(): # rsync made. It now has its own rule (`_SPLIT_OPTIONS_WITH_VALUE`) and its own file — # `tests/test_bash_tool_split_prefix.py`. # -# `zip` was pinned here with the reason above and has left it, the third row to do -# so: its archive is readable from the first *operand* — no flag grammar needed to +# `zip` was pinned here with the reason above and has left it the same way `rsync` and +# `split` did: its archive is readable from the first *operand* — no flag grammar needed to # place it — and the read half is a spelling (`-sf`/`--show-files` for show-files, # `-T` only while it has no list) rather than the "value of an option". Its own rule # is `_zip_write_targets`, its measured table is the comment above From 93794f6af9a3b059947545bac366cc9e964d9bed Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 23:05:30 +0800 Subject: [PATCH 4/6] emrg: the zip note stands on its own, not on the paragraph it replaces --- tests/test_bash_tool_option_destinations.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_bash_tool_option_destinations.py b/tests/test_bash_tool_option_destinations.py index 99fee9f4..333cc2db 100644 --- a/tests/test_bash_tool_option_destinations.py +++ b/tests/test_bash_tool_option_destinations.py @@ -239,8 +239,7 @@ def test_the_cluster_spelling_is_a_measured_residual_not_a_guess(): # rsync made. It now has its own rule (`_SPLIT_OPTIONS_WITH_VALUE`) and its own file — # `tests/test_bash_tool_split_prefix.py`. # -# `zip` was pinned here with the reason above and has left it the same way `rsync` and -# `split` did: its archive is readable from the first *operand* — no flag grammar needed to +# `zip` was pinned here and has left it the same way `rsync` and `split` did: its archive is readable from the first *operand* — no flag grammar needed to # place it — and the read half is a spelling (`-sf`/`--show-files` for show-files, # `-T` only while it has no list) rather than the "value of an option". Its own rule # is `_zip_write_targets`, its measured table is the comment above From 077c99203a6fbfc1093e5dd682dda2444c65d705 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 20 Sep 2026 00:20:33 +0800 Subject: [PATCH 5/6] emrg: zip -P is a spaced value, so the walk names the archive and not the password --- emrg/tools/bash_tool.py | 23 ++++++++++++++++++++++- tests/test_bash_tool_zip_archive.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index ba692c2b..c9bd60b6 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -351,7 +351,28 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: # is show-files while `-f` is freshen (a write), `-L` is the licence while `-l` # is the LF->CRLF conversion (a write, measured above). A letter scan — the # shape the compressor family uses — would conflate both pairs. -_ZIP_OPTIONS_WITH_VALUE = frozenset({"-b", "-t", "-n", "-s", "-TT"}) +# +# 5. `-P ` is the family's **sixth** spaced value, and it was the one +# this table was short. Measured on the same binary 2026-09-20, one fresh +# directory per row holding `f`: +# +# zip -P secret a.zip f rc=0, **a.zip created** — the archive is still the +# first operand, the password is an option's value +# zip -P a.zip f rc=12 nothing written (`a.zip` was eaten as the +# password, so `f` is the archive with no list) +# zip -Psecret a.zip f rc=0, a.zip created — the **attached** spelling +# zip -P secret a.zip rc=12 nothing written +# +# The attached spelling never needed the table (the token begins with `-`, so +# `_positional_args` drops it either way), which is exactly why the spaced one +# went unnoticed: with `-P` absent from the table the walk named the +# **password** as the archive. That is the wrong name `_positional_args`' +# docstring calls a guard nobody can trust *and* it is a hole in the direction +# this rule exists for — measured through the predicate on the branch this +# table was written on: `zip -P ./pw /a.zip f` named `./pw` and was +# **allowed at `workspace-write`** while really rewriting the archive outside +# every allowed root, because the wrong token resolved inside the workspace. +_ZIP_OPTIONS_WITH_VALUE = frozenset({"-b", "-t", "-n", "-s", "-TT", "-P"}) _ZIP_READ_TOKENS = frozenset({ "-sf", "-su", "-sU", "-h", "-h2", "-L", "--help", "--version", "--show-files", diff --git a/tests/test_bash_tool_zip_archive.py b/tests/test_bash_tool_zip_archive.py index 2042f9b9..e4f000e4 100644 --- a/tests/test_bash_tool_zip_archive.py +++ b/tests/test_bash_tool_zip_archive.py @@ -121,6 +121,22 @@ def test_the_predicate_is_the_one_the_tiers_read(): "zip -t 20010101 {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), ("spaced -TT command", "zip -TT 'unzip -tqq' {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # `-P` was the sixth spaced value and the one the table was short: the walk + # named the *password* instead of the archive, and when that token resolved + # inside the workspace the run was allowed at `workspace-write` while it + # really rewrote the archive outside every allowed root. Measured on the + # host's binary 2026-09-20: `zip -P secret a.zip f` is rc=0 and creates + # `a.zip`, i.e. the archive is still the first operand of the run. + ("spaced -P password", + "zip -P secret {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -P password, attached", + "zip -Psecret {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # The hole shape, spelled out: the password is a path *inside* the workspace, + # so a rule that named it left the real write to `{out}/a.zip` unnamed and + # both tiers allowed the run. This row is the one that reds in that + # direction; the two above red on the name. + ("spaced -P password inside the workspace", + "zip -P {ws}/pw {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), # A resolved verb, a chain and a nested shell all reach the same rule. ("absolute path to the verb", "/usr/bin/zip {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), @@ -220,6 +236,12 @@ def test_an_archive_inside_the_workspace_is_allowed_where_writes_are_on(): ("archive and no list", "zip {out}/a.zip"), ("delete and no members", "zip -d {out}/a.zip"), ("verbose and no list", "zip -v {out}/a.zip"), + # `-P`'s value is the *next* token, so here it eats the archive and the run + # has only one operand left: measured rc=12, nothing written. Before `-P` + # joined the value table the walk named that eaten token — a false block on a + # run that writes nothing at all. + ("password eats the archive", "zip -P {out}/a.zip {ws}/in.txt"), + ("password and no list", "zip -P secret {out}/a.zip"), ) From 36ee91718e9daec1365b015d53a4ca05f2453e51 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sun, 20 Sep 2026 00:29:32 +0800 Subject: [PATCH 6/6] emrg: zip -tt, -Z and -lf are spaced values, and -lf names a path zip writes --- emrg/tools/bash_tool.py | 101 ++++++++++++++-- tests/test_bash_tool_zip_archive.py | 178 ++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 10 deletions(-) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index c9bd60b6..a885fd8b 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -372,7 +372,37 @@ def _translate_windows_heredocs(cmd: str) -> tuple[str, str | None]: # table was written on: `zip -P ./pw /a.zip f` named `./pw` and was # **allowed at `workspace-write`** while really rewriting the archive outside # every allowed root, because the wrong token resolved inside the workspace. -_ZIP_OPTIONS_WITH_VALUE = frozenset({"-b", "-t", "-n", "-s", "-TT", "-P"}) +# +# 6. Three more spaced values were still missing from the table after that fix — +# `-tt `, `-Z ` and `-lf ` — each measured the same way +# (2026-09-20, one fresh directory per row holding `f`): +# +# zip -tt 20200101 a.zip f rc=12, "invalid date entered for -tt option — +# use mmddyyyy or yyyy-mm-dd": the option **ate** +# the token, so with a valid date the archive is +# whatever follows it +# zip -Z store a.zip f rc=0, a.zip created, and **no file named +# `store`** — the method name is consumed +# zip -lf ./log a.zip f rc=0, a.zip created, `log.log` created +# zip -lf./log2 a.zip f rc=0, `log2.log` created — the attached spelling +# +# The first two are values that are never paths, so the table is all they +# need. `-lf` is different: its value **is** a path zip writes, so the table +# alone would stop naming it. Three properties decide how it is named, all +# measured on the same binary: +# +# zip -sf -lf ./log a.zip rc=0, the listing printed and `log.log` CREATED — +# a read spelling still writes the logfile, so it +# has to survive the read short-circuit +# zip -lf ./log a.zip "zip error: Nothing to do!" and `log.log` still +# CREATED — so it survives the writes-nothing case +# zip -lf ./log3 a.zip f `log3.log` written: zip appends `.log` when the +# value does not already end in it, which lands in +# the same directory, so naming the token as +# written is containment-equivalent +_ZIP_OPTIONS_WITH_VALUE = frozenset({ + "-b", "-t", "-tt", "-n", "-s", "-TT", "-P", "-Z", "-lf", +}) _ZIP_READ_TOKENS = frozenset({ "-sf", "-su", "-sU", "-h", "-h2", "-L", "--help", "--version", "--show-files", @@ -2360,19 +2390,53 @@ def _lz4_write_targets(tokens: list[str], i: int) -> list[str]: return operands[-1:] +def _zip_logfile_targets(words: list[str]) -> list[str]: + """The path ``zip -lf `` writes, in both measured spellings. + + The rows behind this are consequence 6 of the table above + `_ZIP_OPTIONS_WITH_VALUE`. ``-lf`` is the family's one spaced value that is + itself a **path**, so it is a write the archive rule cannot reach: the option + consumes the token (it is not an operand, so nothing in the operand walk sees + it) while zip opens it as a logfile. It is named in **every** shape of the + run, because it is written in every shape measured — the read spellings + (``zip -sf -lf ./log a.zip`` printed its listing and still created ``log.log``) + and the exit-12 "Nothing to do!" case (``zip -lf ./log a.zip``) included. + + ``zip`` appends ``.log`` when the value does not end in it, which lands in the + same directory as the token named here, so naming the token as written is + containment-equivalent — the question a block asks. + + ``-Z `` and ``-tt `` are handled by the table alone: their values are + never paths, so consuming them is the whole rule. + """ + out: list[str] = [] + for idx, tok in enumerate(words): + if tok == "--": + break + if tok == "-lf": + if idx + 1 < len(words): + out.append(words[idx + 1]) + elif tok.startswith("-lf"): + out.append(tok[3:]) + return out + + def _zip_write_targets(tokens: list[str], i: int) -> list[str]: """The paths a ``zip`` run writes: its **first** operand, and what it moves. The measured table is the comment above `_ZIP_OPTIONS_WITH_VALUE`; the rule it - settles is three lines long, and each line is one of its rows: + settles is four lines long, and each line is one of its rows: * a read spelling (``-sf``/``--show-files``, ``-su``/``-sU``, the help and - licence forms) writes nothing, so nothing is named; + licence forms) writes no archive — the ``-lf`` logfile is the exception, + written in every shape (see `_zip_logfile_targets`); * with no second operand the run writes nothing at all (exit 12, "Nothing to - do!"), which is what keeps `zip a.zip` and `zip -d a.zip` allowed; + do!"), which is what keeps `zip a.zip` and `zip -d a.zip` allowed — the same + logfile exception applies there too; * otherwise the archive — the *first* operand — is the path that is created or rewritten, and under ``-m``/``--move`` every listed operand after it is - removed as well. + removed as well; + * and the ``-lf`` value is named alongside whichever of the above applies. Named limit: the exclusion list (``-x``) and the include list (``-i``) are matched against the operands **by name**, and a name they neutralise is still @@ -2387,17 +2451,34 @@ def _zip_write_targets(tokens: list[str], i: int) -> list[str]: taken in a scratch directory, `zip -b a.zip f` left the directory **empty** afterwards, and so did a run that failed — the temporary archive is removed before the process exits, so there is no surviving path to protect. + + ``--out `` (copy mode's destination, `zip -U`) is a **measured limit** + rather than a treated case: its value is a path, and the rule it needs is + mode-sensitive, so it is filed as issue #1441 instead of guessed here. Measured + on the same binary, one fresh directory holding a pre-built `src.zip`: + `zip -U src.zip --out out.zip` is rc=0, creates `out.zip` and leaves `src.zip` + untouched — so in copy mode the **first operand is a read**. The walk has no + copy-mode rule and names that operand, which is wrong in both directions + (measured through this predicate at `workspace-write`): + `zip -U /workspace/src.zip --out /outside/emrg/o.zip` is **allowed** while + naming the source, so the archive really written outside every allowed root is + named by nothing; and `zip -U /outside/emrg/src.zip --out /workspace/o.zip` is + **blocked on the read**. Adding `--out` to the table would not help — the table + means "consumes the next token, which is not a path", and this value is the + destination, so the operand before it would be named again. """ words = _args_after_command(tokens, i) + logfile = _zip_logfile_targets(words) if any(tok in _ZIP_READ_TOKENS for tok in words): - return [] + return logfile operands = _positional_args(tokens, i, _ZIP_OPTIONS_WITH_VALUE) if len(operands) < 2: - # Archive and no list: zip exits 12 having written nothing. - return [] + # Archive and no list: zip exits 12 having written nothing — the logfile + # excepted, which it really does create (measured). + return logfile if any(tok in _ZIP_MOVE_FLAGS for tok in words): - return operands - return operands[:1] + return operands + logfile + return operands[:1] + logfile def _is_directory_install(tokens: list[str], i: int) -> bool: diff --git a/tests/test_bash_tool_zip_archive.py b/tests/test_bash_tool_zip_archive.py index e4f000e4..3fc8fbba 100644 --- a/tests/test_bash_tool_zip_archive.py +++ b/tests/test_bash_tool_zip_archive.py @@ -137,6 +137,27 @@ def test_the_predicate_is_the_one_the_tiers_read(): # direction; the two above red on the name. ("spaced -P password inside the workspace", "zip -P {ws}/pw {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # `-tt`, `-Z` and `-lf` were the next three spaced values the same table was + # short of, each measured 2026-09-20 on the same binary. `zip -tt 20200101 + # a.zip f` exits 12 with "invalid date entered for -tt option" — the option + # **ate** the token, so the archive sits one position further right; `zip -Z + # store a.zip f` is rc=0 and creates no file named `store`; `zip -lf ./log + # a.zip f` is rc=0 and creates `log.log`. Absent from the table, the walk + # named the date, the method and the log path respectively, so + # `zip -tt 20010101 {out}/a.zip f` was **allowed** at `workspace-write` + # while really rewriting the archive outside every allowed root. + ("spaced -tt date", + "zip -tt 20010101 {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + ("spaced -Z method", + "zip -Z store {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), + # `-lf`'s value is a **path** zip writes, so it is named *beside* the archive + # rather than consumed and forgotten: the run writes both. Both spellings, + # because the attached one was measured to work (`zip -lf./log2 a.zip f` + # created `log2.log`). + ("spaced -lf logfile", + "zip -lf {ws}/log {out}/a.zip {ws}/in.txt", ("{out}/a.zip", "{ws}/log")), + ("attached -lf logfile", + "zip -lf{ws}/log {out}/a.zip {ws}/in.txt", ("{out}/a.zip", "{ws}/log")), # A resolved verb, a chain and a nested shell all reach the same rule. ("absolute path to the verb", "/usr/bin/zip {out}/a.zip {ws}/in.txt", ("{out}/a.zip",)), @@ -513,3 +534,160 @@ def test_the_lowercase_l_really_writes_and_the_uppercase_L_does_not(tmp_path): assert result.returncode == 0, result.stderr assert not os.path.exists(upper), "uppercase `-L` created the archive" assert _extract_write_targets(f"zip -L {upper} {operand}") == [] + + +# ── the values that are consumed but are not paths: `-tt`, `-Z` ──────────────────── +def test_the_new_table_rows_are_what_moves_the_naming_onto_the_archive() -> None: + """Drop the rows the table was short of and the hole comes back, measured. + + Consequence 6 of `bash_tool.py`'s table claims `-tt`, `-Z` and `-lf` are what + moves the naming onto the archive. Read in both directions: with the table as + written the row is refused, and with `-tt`/`-Z` removed (the state before this + cycle) the walk names the date again and the run is **allowed** while it really + rewrites the archive outside every allowed root — the exact hole measured on + the host. + """ + cmd = f"zip -tt 20010101 {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + assert _extract_write_targets(cmd) == [f"{OUTSIDE}/a.zip"] + assert tiers(cmd)["workspace-write"] is False + + original = bash_tool._ZIP_OPTIONS_WITH_VALUE + try: + bash_tool._ZIP_OPTIONS_WITH_VALUE = original - {"-tt", "-Z"} + assert _extract_write_targets(cmd) == ["20010101"], ( + "with `-tt` out of the table the walk must name the date — that wrong " + "name is what the row exists to prevent" + ) + assert tiers(cmd)["workspace-write"] is True, ( + "with `-tt` out of the table the run is allowed while it really " + "rewrites the archive outside — the hole, restored" + ) + finally: + bash_tool._ZIP_OPTIONS_WITH_VALUE = original + + +@needs_zip +def test_tt_and_Z_really_consume_the_token_they_are_given(tmp_path): + """Executed, because the table's whole meaning is "this option eats the next one". + + `-tt` gets a **future** date so the run succeeds rather than relying on an error + message: every file is then "before" the bound, so the archive is created — and + no file named after the date exists, which is what says the date was consumed + rather than taken as the archive. `-Z` is the same shape: the method name is + eaten, so the archive is the operand and no file named `store` appears. + """ + operand = tmp_path / "in.txt" + operand.write_text("hello\n") + + dated = tmp_path / "dated.zip" + result = subprocess.run(["zip", "-tt", "2099-01-01", str(dated), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, (result.stdout, result.stderr) + assert dated.exists(), "the archive named after the date was not created" + assert not (tmp_path / "2099-01-01").exists(), ( + "zip treated the date as a file name, so `-tt` does not consume its value" + ) + + method = tmp_path / "method.zip" + result = subprocess.run(["zip", "-Z", "store", str(method), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, (result.stdout, result.stderr) + assert method.exists(), "the archive named after the method was not created" + assert not (tmp_path / "store").exists(), ( + "zip treated the method as a file name, so `-Z` does not consume its value" + ) + + +# ── `-lf`: a spaced value that is itself a path zip writes ────────────────────────── +def test_the_logfile_is_named_where_no_archive_is() -> None: + """`-lf` writes a path the archive rule cannot reach, so it is named on its own. + + Three shapes, all measured on the host (consequence 6): a read spelling still + creates the logfile, the exit-12 "Nothing to do!" case still creates it, and it + is written in addition to the archive when there is one. Both directions, + because a rule that only added the value to the table would name the logfile + nowhere and let it be written outside every allowed root. + """ + read_row = f"zip -sf -lf {OUTSIDE}/log {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + no_list_row = f"zip -lf {OUTSIDE}/log {OUTSIDE}/a.zip" + write_row = f"zip -lf {OUTSIDE}/log {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + attached_row = f"zip -lf{OUTSIDE}/log {OUTSIDE}/a.zip {WORKSPACE}/in.txt" + + assert _extract_write_targets(read_row) == [f"{OUTSIDE}/log"] + assert _extract_write_targets(no_list_row) == [f"{OUTSIDE}/log"] + assert _extract_write_targets(write_row) == [f"{OUTSIDE}/a.zip", f"{OUTSIDE}/log"] + assert _extract_write_targets(attached_row) == [f"{OUTSIDE}/a.zip", f"{OUTSIDE}/log"] + for row in (read_row, no_list_row, write_row): + assert tiers(row)["workspace-write"] is False, row + + original = bash_tool._zip_logfile_targets + try: + bash_tool._zip_logfile_targets = lambda words: [] + assert _extract_write_targets(read_row) == [], ( + "the read shape must go back to naming nothing when the logfile rule is " + "blinded — otherwise it is spared by something else" + ) + assert _extract_write_targets(no_list_row) == [], ( + "the no-list shape names the logfile and nothing else, so blinding the " + "rule must leave it naming nothing" + ) + assert _extract_write_targets(write_row) == [f"{OUTSIDE}/a.zip"], ( + "with the logfile rule blinded only the archive should be named" + ) + assert tiers(read_row)["workspace-write"] is True, ( + "with the logfile rule blinded the run is allowed while it really " + "creates the logfile outside — the hole, restored" + ) + finally: + bash_tool._zip_logfile_targets = original + + +@needs_zip +def test_the_logfile_is_really_written_in_every_shape(tmp_path): + """Executed: `-lf ` creates exactly the file it names. + + The suffix matters to the *test* rather than to the rule: zip appends `.log` to a + value that does not already end in it (measured), so naming a value with the + suffix makes the file asserted here the same token the walk names. + """ + operand = tmp_path / "in.txt" + operand.write_text("hello\n") + archive = tmp_path / "a.zip" + subprocess.run(["zip", "-q", str(archive), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + + # a read spelling still writes the logfile + read_log = tmp_path / "read.log" + result = subprocess.run(["zip", "-sf", "-lf", str(read_log), str(archive)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, (result.stdout, result.stderr) + assert read_log.exists(), ( + "`zip -sf -lf ` did not create the logfile, so the read short-circuit " + "would be right after all" + ) + assert _extract_write_targets( + f"zip -sf -lf {read_log} {archive} {operand}" + ) == [str(read_log)] + + # the exit-12 "Nothing to do!" case writes it too + idle_log = tmp_path / "idle.log" + result = subprocess.run(["zip", "-lf", str(idle_log), str(tmp_path / "none.zip")], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode != 0, "the no-list run unexpectedly succeeded" + assert idle_log.exists(), "the no-list shape did not create the logfile" + assert _extract_write_targets( + f"zip -lf {idle_log} {tmp_path / 'none.zip'}" + ) == [str(idle_log)] + + # and the ordinary shape writes both the archive and the logfile + both_log = tmp_path / "both.log" + both_archive = tmp_path / "both.zip" + result = subprocess.run(["zip", "-lf", str(both_log), str(both_archive), str(operand)], + capture_output=True, text=True, encoding="utf-8", errors="replace") + assert result.returncode == 0, (result.stdout, result.stderr) + assert both_log.exists() and both_archive.exists(), ( + "the ordinary shape must write the archive and the logfile" + ) + assert _extract_write_targets( + f"zip -lf {both_log} {both_archive} {operand}" + ) == [str(both_archive), str(both_log)]