From aa34fa4b2dca745cf8a17fd7abdc00d60d1be561 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Sat, 19 Sep 2026 23:55:42 +0800 Subject: [PATCH] emrg: a bare dash is a stream, not a path to the compressors --- emrg/tools/bash_tool.py | 66 ++++++++++++++- .../test_bash_tool_compressor_enumeration.py | 54 ++++++++++++ tests/test_bash_tool_compressor_operands.py | 83 ++++++++++++++++++- 3 files changed, 199 insertions(+), 4 deletions(-) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index e81adc92..7acdaec9 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -1969,10 +1969,15 @@ def is_separator(index: int) -> bool: elif word in _COMPRESSOR_VERBS: # `gzip f` rewrites f in place; the read spellings (`gzip -c f`, # `gzip -t f`, `gzip -l f`) leave it alone and must stay allowed. - # This gate is why the family is not simply in the set above. + # This gate is why the family is not simply in the set above. A bare + # `-` operand is the family's stdin/stdout spelling and is dropped + # one operand at a time, because `gzip - f` really does compress `f` + # — see `_without_the_stream_operand`. if not _compressor_operand_is_a_read(tokens, i): targets.extend( - _positional_args(tokens, i, _COMPRESSOR_OPTIONS_WITH_VALUE) + _without_the_stream_operand( + _positional_args(tokens, i, _COMPRESSOR_OPTIONS_WITH_VALUE) + ) ) elif word in _LZ4_VERBS: # `lz4 f` writes `f.lz4` beside the operand rather than rewriting it, @@ -2214,6 +2219,46 @@ def _compressor_operand_is_a_read(tokens: list[str], i: int) -> bool: return False +def _without_the_stream_operand(targets: list[str]) -> list[str]: + """``targets`` without the bare ``-`` — the operand that is not a path. + + A bare ``-`` is the convention for *standard input, standard output*: the + program reads the stream and writes the stream, so no file is opened under + that name and naming it turns a pure read into a refusal. This walk refuses + in the direction its own record treats as the costlier one — an empty target + list is a hole it can be argued out of, a refusal is a command a reader + cannot run — so the operand is dropped rather than guessed at. + + Measured on this host 2026-09-19, one **fresh** directory per row with the + input present and the listing read back off disk afterwards: + + gzip - xz - bzip2 - zstd - compress - lz4 - rc=0, no file created + gzip -9 - gzip -- - xz -9 - bzip2 -9 - rc=0, no file created + zstd -19 - lz4 -9 - rc=0, no file created + gzip -d - rc=1 (`unexpected end of + file`), no file created + gzip - f gzip f - xz - f zstd - f `f` IS compressed — + `gzip - f` writes + `f.gz` and removes `f` + + The last row is why this is applied **per operand** and not per run, and why + `_compressor_operand_is_a_read` is left alone: that gate answers once for the + whole run, so teaching it the bare ``-`` would have made `gzip - f` name + nothing at all — a hole, in exchange for nothing, since the operand beside + the dash is a real path and must still be named. Dropping the token keeps + ``['f']`` for that row and ``[]`` for `gzip -`. + + The drop is deliberately not made in ``_positional_args``, where it would + reach every verb. A bare ``-`` is a *path* to the other writers, measured in + the same geometry: `touch -`, `truncate -s0 -`, `mv src.txt -` and + `cp src.txt -` each create the file named ``-`` (and `chmod 777 -` and `rm -` + look one up), so a global drop would open exactly the hole the everyday + writers are named to close. It is the compressor family's own spelling, and + it is dropped where that is measured. + """ + return [t for t in targets if t != "-"] + + def _lz4_letters(args: list[str]) -> set[str]: """The short-option letters of an ``lz4`` run, read cluster by cluster. @@ -2261,6 +2306,15 @@ def _lz4_write_targets(tokens: list[str], i: int) -> list[str]: * otherwise the *last* operand is written — exactly the explicit destination of `lz4 f out.lz4`, and for the single-operand form the operand itself, whose directory is where the derived sibling lands. + + A bare ``-`` operand is the stream, in either position, and is never named; + which operand it is takes the third bullet with it. Measured in one fresh + directory per row with only `f` present and the listing read back off disk + (2026-09-19): `lz4 -`, `lz4 - -`, `lz4 -t -` and **`lz4 f -`** all create no + file — the last one names stdout as its destination, so nothing is written + beside `f` either — while `lz4 -m f -` really does derive `f.lz4`, which is + why the multi-input branch drops the token and keeps `f`. See + `_without_the_stream_operand`. """ args = _args_after_command(tokens, i) letters = _lz4_letters(args) @@ -2268,7 +2322,13 @@ def _lz4_write_targets(tokens: list[str], i: int) -> list[str]: return [] operands = _positional_args(tokens, i, _LZ4_OPTIONS_WITH_VALUE) if letters & _LZ4_MULTI_LETTERS or any(t in _LZ4_MULTI_LONG for t in args): - return operands + return _without_the_stream_operand(operands) + if operands and operands[-1] == "-": + # `lz4 f -` names stdout as its destination instead of writing `f.lz4` + # beside the operand, so nothing on disk is written under *any* operand + # and the last-operand rule has to answer with nothing rather than with + # the operand it would otherwise fall back on (`f`, which it only reads). + return [] return operands[-1:] diff --git a/tests/test_bash_tool_compressor_enumeration.py b/tests/test_bash_tool_compressor_enumeration.py index 2aaf9dee..2ed6ab7b 100644 --- a/tests/test_bash_tool_compressor_enumeration.py +++ b/tests/test_bash_tool_compressor_enumeration.py @@ -73,6 +73,13 @@ # `-m` makes every operand an input, each deriving its own sibling. ("multiple inputs", f"lz4 -m {OUTSIDE}/f {OUTSIDE}/g", (f"{OUTSIDE}/f", f"{OUTSIDE}/g")), + # …with one of the inputs a stream: measured on the host 2026-09-19 in a fresh + # directory holding only `f`, `lz4 -m f -` is rc=0 and **does** derive `f.lz4`, + # so the file must still be named while the bare `-` is not. Naming the token + # was the false block; dropping the whole run would have been the hole. The row + # carries that measured shape with an outside path, so both tiers have the file + # — rather than the stream token — to refuse. + ("multiple inputs with a stream", f"lz4 -m - {OUTSIDE}/f", (f"{OUTSIDE}/f",)), ("recursive", f"lz4 -r {OUTSIDE}/f", (f"{OUTSIDE}/f",)), ("block size", f"lz4 -B4 {OUTSIDE}/f", (f"{OUTSIDE}/f",)), ("threads", f"lz4 -T4 {OUTSIDE}/f", (f"{OUTSIDE}/f",)), @@ -109,6 +116,18 @@ # read, or "a value follows" would swallow real flags. Measured — `lz4 -Dcats # -c f` is rc=0, 34 bytes on stdout and no file created. ("attached dictionary then -c", f"lz4 -D{OUTSIDE}/cats -c {OUTSIDE}/f"), + # A bare `-` is the stream, in either position. Measured on the host + # 2026-09-19, one fresh directory per row with only `f` present and the + # listing read back off disk: `lz4 -`, `lz4 -9 -` and `lz4 - -` are rc=0 and + # create no file, and `lz4 f -` is rc=0 with **nothing beside `f`** either — + # its last operand names stdout as the destination rather than deriving a + # sibling, so the last-operand rule has to answer with nothing instead of + # falling back on the operand it only reads. (`lz4 -t -` is rc=44 and creates + # nothing too, though it is spared by the read letters before this rule.) + ("bare dash", "lz4 -"), + ("bare dash with a level", "lz4 -9 -"), + ("both operands the stream", "lz4 - -"), + ("dash as the destination", "lz4 f -"), ) # (row, command) — the compressors *not* on `_COMPRESSOR_VERBS` and not read by a @@ -278,6 +297,41 @@ def test_the_multi_input_rule_is_what_spares_the_second_operand() -> None: bash_tool._LZ4_MULTI_LETTERS, bash_tool._LZ4_MULTI_LONG = letters, longs +def test_the_stream_operand_is_what_spares_the_bare_dash_here_too() -> None: + """The same drop as the family's, in this verb's multi-input branch. + + With it removed that row names the bare `-` again and goes back to the + refusal master gave, i.e. the row is refused *because* the token is dropped. + The destination row (`lz4 f -`) is guarded by the last-operand branch's own + test rather than by the drop — the drop would leave `f` there, which is the + operand it only reads — so it is asserted in both states instead of flipped. + """ + drop = bash_tool._without_the_stream_operand + multi = "lz4 -m f -" + destination = "lz4 f -" + + assert _extract_write_targets(multi) == ["f"] + assert _extract_write_targets(destination) == [] + assert _check_sandbox(destination, "read-only", workdir="/workspace")[0] is True + + try: + bash_tool._without_the_stream_operand = lambda targets: list(targets) + assert _check_sandbox(multi, "read-only", workdir="/workspace")[0] is False, ( + "with the drop removed the stream operand must be named again — " + "otherwise the drop is not what spares it" + ) + assert _extract_write_targets(destination) == [], ( + "the destination row is not the drop's to spare: it must stay empty " + "with the drop removed too, or `f` would be named for a run that " + "writes to stdout" + ) + finally: + bash_tool._without_the_stream_operand = drop + + assert _extract_write_targets(multi) == ["f"] + assert _extract_write_targets(destination) == [] + + def test_the_attached_value_is_what_stops_the_letter_scan() -> None: """The stop is its own piece of the rule, so it is flipped on its own. diff --git a/tests/test_bash_tool_compressor_operands.py b/tests/test_bash_tool_compressor_operands.py index 46004f6f..39b8fcda 100644 --- a/tests/test_bash_tool_compressor_operands.py +++ b/tests/test_bash_tool_compressor_operands.py @@ -24,7 +24,11 @@ `zcat` idiom, so the letter is read inside a short cluster as well; * the **`*cat` wrappers** (`zcat`, `bzcat`, `xzcat`, `zstdcat`) are not in the family at all: they are `-dc` wrappers that write nothing, and a rule that - named their operand would refuse a pure read. + named their operand would refuse a pure read; +* the family's own **stream operand** — a bare `-` — is not a path, so it is + dropped from a write form one operand at a time, while the file beside it is + still named (`gzip - f` really compresses `f`; see + `test_the_stream_operand_is_what_spares_the_bare_dash`). Nothing here executes a command. `_check_sandbox` is a pure predicate — it `realpath`s a path and opens nothing — and `_extract_write_targets` only parses, @@ -96,6 +100,14 @@ ("uncompress", f"uncompress {OUTSIDE}/f.Z", (f"{OUTSIDE}/f.Z",)), # Two operands: both are rewritten, so both are named. ("two operands", f"gzip {OUTSIDE}/a {OUTSIDE}/b", (f"{OUTSIDE}/a", f"{OUTSIDE}/b")), + # …and the drop below is **per operand**, not per run: measured on the host + # 2026-09-19 in a fresh directory holding only `f`, `gzip - f` is rc=0 and + # writes `f.gz` while removing `f`, so the file beside the stream must still + # be named. A run-level "this is a read form" answer for the bare `-` would + # have named nothing here, i.e. a hole in exchange for the false block. The + # row carries the measured shape with an outside path, so that both tiers have + # the file — rather than the stream token — to refuse. + ("dash beside a file", f"gzip - {OUTSIDE}/f", (f"{OUTSIDE}/f",)), ) # (row, command) — the spellings whose operand is a *read*. Each must name @@ -118,6 +130,22 @@ # `compress` takes `-c` and only `-c` of the three letters. ("compress -c", f"compress -c {OUTSIDE}/f"), ("uncompress -c", f"uncompress -c {OUTSIDE}/f.Z"), + # A bare `-` is this family's own stdin/stdout spelling: the program reads the + # stream and writes the stream, so no file is opened under that name. Measured + # on the host 2026-09-19, one **fresh** directory per row with the input present + # and the listing read back off disk: every row below creates no file, and + # `gzip -- -` / `gzip -9 -` leave a file literally named `-` untouched as well. + # Before the drop each of them was refused — a false block, the direction this + # file's own record treats as the costlier one. + ("bare dash", "gzip -"), + ("bare dash with a level", "gzip -9 -"), + ("bare dash after --", "gzip -- -"), + ("bare dash, bzip2", "bzip2 -"), + ("bare dash, xz", "xz -"), + ("bare dash, zstd", "zstd -"), + ("bare dash, compress", "compress -"), + ("bare dash, uncompress", "uncompress -"), + ("bare dash, decompress", "gzip -d -"), ) # The wrappers, which are reads by construction and must not be in the family. @@ -284,3 +312,56 @@ def test_the_read_gate_is_what_spares_the_read_forms() -> None: ) finally: bash_tool._compressor_operand_is_a_read = gate + + +def test_the_stream_operand_is_what_spares_the_bare_dash() -> None: + """The drop is a second piece of code, so it gets its own arm. + + Without it a bare `-` goes back to being named, and every row in the dash + block above returns to the refusal master gave. That is the whole claim: the + rows are refused *because* the token is dropped, not because something else + in the walk happens to spare them. + """ + drop = bash_tool._without_the_stream_operand + row = "gzip -" + beside = "gzip - f" + + assert _extract_write_targets(row) == [] + assert _check_sandbox(row, "read-only", workdir="/workspace")[0] is True + assert _check_sandbox(beside, "read-only", workdir="/workspace")[0] is False + + try: + bash_tool._without_the_stream_operand = lambda targets: list(targets) + assert _check_sandbox(row, "read-only", workdir="/workspace")[0] is False, ( + "with the drop removed the bare dash must return to the ALLOW master " + "gave — otherwise the drop is not what spares it" + ) + # …and the row beside it must *not* move: it is refused for naming `f`, + # which is what makes the drop a per-operand rule rather than a per-run one. + assert _extract_write_targets(beside) == ["-", "f"] + finally: + bash_tool._without_the_stream_operand = drop + + assert _extract_write_targets(row) == [] + assert _extract_write_targets(beside) == ["f"] + + +def test_the_drop_is_the_compressors_alone() -> None: + """The boundary the drop must not cross, measured in the same geometry. + + A bare `-` is the compressors' stream operand. To the everyday writers it is + a **path**, and a file named ``-`` is exactly what they act on: measured on + this host 2026-09-19, one fresh directory per row with only `src.txt` present + (no dash file), `touch -`, `truncate -s0 -`, `mv src.txt -` and `cp src.txt -` + each *create* the file named ``-`` at rc=0, while `rm -` and `chmod 777 -` look + one up (rc=1, `-: No such file or directory`). Dropping the token inside + `_positional_args`, where every verb would inherit it, would therefore open + the hole those rows are named to close — this test pins the drop to the family + that measured it. + """ + for cmd in ("touch -", "truncate -s0 -", "mv src.txt -", "cp src.txt -"): + assert _extract_write_targets(cmd) == ["-"], cmd + assert _extract_write_targets(cmd) != [], ( + f"{cmd}: an empty target list here is the everyday writers' hole" + ) +