refactor: close the cleanup plan — execute_node split, candidates() deletion, integ facets (+2 cmp fixes)

Items 32, 34 and 37 of the 37-item cleanup plan.

Item 37b: execute_node's two oversized arms (377 of ~820 lines) move to
siblings named for their node kind — workspace/node/declaration.{py,ts}
and workspace/node/assignment.{py,ts}. Verbatim move, two-line
delegations left behind; execute_node.py 1464 -> 715, .ts 1617 -> 842.
expand_array_items becomes a named cross-module seam. The setAttr
allowlist gates (py+ts) follow _stamp_export to its new module.

Item 34: delete candidates() from runtime/table.ts — zero non-test
callers, no Python counterpart. RUNTIMES stays (py derives NAMED from
it; TS can't, Runtime.name is an instance field).

Item 32: the missing integ facets — cmp/{n,b,i}, df/units, gzip/f,
ls/t, checksum/tag. mktemp was already covered (stale plan entry) and
the checksum --check companions are covered collectively, so the
per-command fact pinned instead is the --tag algorithm name.

The cmp facets found two real bugs, both GNU-pinned before the fix:
-b was ignored under -l (and the octal column is right-aligned to 3 in
both modes), and -i N:M crashed with a leaked ValueError. Fixing them
pulled in GNU size suffixes for -n/-i, cat -v byte rendering, the
byte/char wording switch, and the EOF notice as a stderr diagnostic
naming byte and line. Python's cross-mount relay re-derived flags
instead of calling parse_flags, which is how it kept reading -i as a
bare int; it now delegates like the TS relay already did.

cmp had no unit tests in either tree; 19 mirrored ones per side now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bytecii
2026-08-18 05:04:56 -07:00
parent bc0adbd248
commit 5a9aadf289
29 changed files with 3624 additions and 1686 deletions
+6 -3
View File
@@ -1,7 +1,10 @@
{
"baseline": 2295,
"baseline_reason": "Every dropped target counted here predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a case file regains a backend; the gate fails on a drop too, so an improvement cannot be silently spent. The unit is one (case, target) pair, never (file, target): a sequence-style file states one scenario across prep/act/verify cases, so a backend dropped from only the verifying case still runs every step that cannot fail and silently skips the assertion, and a union over the file would report that backend as covered. What this gate exists to catch is the failure mode that hid two real bugs: a backend that cannot pass a case is normally just deleted from that case's `targets`, which turns a divergence into an omission no reviewer can see. integ/unix/mv/empty_dir.json had dropbox and dropbox-root removed for a folder-conflict divergence, and s3/gridfs removed because a directory rename was unimplemented on both -- neither omission said so anywhere. A large share of the remaining count is the tier2/unicode/escape family, whose cases pin text-processing behavior that cannot vary by backend and so run on the core four; those are excusable in bulk once someone confirms that file by file, which is exactly the decision this gate is meant to force rather than assume.",
"files": {},
"baseline": 2289,
"baseline_reason": "Every dropped target counted here predates the gate and each needs its own decision, so --strict fails on a rise rather than demanding zero. Lower this number whenever a case file regains a backend; the gate fails on a drop too, so an improvement cannot be silently spent. The unit is one (case, target) pair, never (file, target): a sequence-style file states one scenario across prep/act/verify cases, so a backend dropped from only the verifying case still runs every step that cannot fail and silently skips the assertion, and a union over the file would report that backend as covered. What this gate exists to catch is the failure mode that hid two real bugs: a backend that cannot pass a case is normally just deleted from that case's `targets`, which turns a divergence into an omission no reviewer can see. integ/unix/mv/empty_dir.json had dropbox and dropbox-root removed for a folder-conflict divergence, and s3/gridfs removed because a directory rename was unimplemented on both -- neither omission said so anywhere. A large share of the remaining count is the tier2/unicode/escape family, whose cases pin text-processing behavior that cannot vary by backend and so run on the core four; those are excusable in bulk once someone confirms that file by file, which is exactly the decision this gate is meant to force rather than assume. Lowered 2295 -> 2289 when cmp/{n,b,i}.json landed: the gate compares each case against its directory's MODAL target set, and cmp's mode was decided by a 2-vs-2 tie broken toward the larger set, so the two fixture-reading cases (basic.json, which only reads /data/a.txt) set the expectation and the two write-seeding ones (l.json, s.json) each reported a 3-target omission. The new cases seed the same way l.json does -- `mkdir -p` plus `printf >` -- which databricks, databricks-prefix and sharepoint-prefix cannot serve, so they carry l.json's list and the write-seeding shape is now the majority. Those 6 pairs are a real capability limit rather than a parked divergence: cmp itself runs on all 22 (basic.json proves it), only the seeding does not. Nothing was narrowed to make a case pass.",
"files": {
"integ/unix/gzip/f.json": "Same list as gzip/k.json, the sibling that pins -k. -f only decides whether an existing archive is overwritten, which every backend in that list already exercises through the write primitive.",
"integ/unix/ls/t.json": "-t sorts on mtime, so the case has to set three distinct mtimes with touch -d first; the list is touch/d.json's, the backends that support setting one."
},
"entries": {
"integ/bash/assign/export_attr.json :: cd_exports_pwd_and_oldpwd": {
"hf": "hf holds no empty directory: the service refuses a directory marker client-side (create_dir=false, probed on opendal 0.47.1), so mkdir is a no-op and a directory exists exactly while it holds a key. This case has to cd into a directory it just made and never keyed, so on hf the cd fails and PWD/OLDPWD never take the values being asserted. What the case pins is the export attribute on the two variables cd writes, which is shell state and cannot vary by backend; hf runs the rest of this file, including the bare `declare -p PWD` case that reads the same attribute without needing a directory.",
+108
View File
@@ -0,0 +1,108 @@
{
"cases": [
{
"id": "checksum_tag_names_md5",
"seq": 910042,
"targets": [
"ram",
"disk"
],
"command": "mkdir -p /data/qcktag && printf 'hello' > /data/qcktag/h.txt && md5sum --tag /data/qcktag/h.txt",
"expect": {
"exit": 0,
"stdout": "MD5 (/data/qcktag/h.txt) = 5d41402abc4b2a76b9719d911017c592\n",
"stderr": ""
},
"flags": [
"tag"
]
},
{
"id": "checksum_tag_names_sha1",
"seq": 910043,
"targets": [
"ram",
"disk"
],
"command": "sha1sum --tag /data/qcktag/h.txt",
"expect": {
"exit": 0,
"stdout": "SHA1 (/data/qcktag/h.txt) = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d\n",
"stderr": ""
},
"flags": [
"tag"
]
},
{
"id": "checksum_tag_names_sha384",
"seq": 910044,
"targets": [
"ram",
"disk"
],
"command": "sha384sum --tag /data/qcktag/h.txt",
"expect": {
"exit": 0,
"stdout": "SHA384 (/data/qcktag/h.txt) = 59e1748777448c69de6b800d7a33bbfb9ff1b463e44354c3553bcdb9c666fa90125a3c79f90397bdf5f6a13de828684f\n",
"stderr": ""
},
"flags": [
"tag"
]
},
{
"id": "checksum_tag_names_sha512",
"seq": 910045,
"targets": [
"ram",
"disk"
],
"command": "sha512sum --tag /data/qcktag/h.txt",
"expect": {
"exit": 0,
"stdout": "SHA512 (/data/qcktag/h.txt) = 9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043\n",
"stderr": ""
},
"flags": [
"tag"
]
},
{
"id": "checksum_short_binary_and_text_markers",
"seq": 910046,
"targets": [
"ram",
"disk"
],
"command": "sha256sum -b /data/qcktag/h.txt; sha256sum -t /data/qcktag/h.txt",
"expect": {
"exit": 0,
"stdout": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 */data/qcktag/h.txt\n2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 /data/qcktag/h.txt\n",
"stderr": ""
},
"flags": [
"b",
"t"
]
},
{
"id": "checksum_tag_output_checks_back",
"seq": 910047,
"targets": [
"ram",
"disk"
],
"command": "mkdir -p /data/qckrt && sha512sum --tag /data/qcktag/h.txt > /data/qckrt/sums && sha512sum -c /data/qckrt/sums; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcktag/h.txt: OK\nrc=0\n",
"stderr": ""
},
"flags": [
"tag",
"c"
]
}
]
}
+142
View File
@@ -0,0 +1,142 @@
{
"cases": [
{
"id": "cmp_b_names_the_byte_and_its_character",
"seq": 601136,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qcmpb && printf 'abc' > /data/qcmpb/b1 && printf 'aXc' > /data/qcmpb/b2 && printf 'a\\001c' > /data/qcmpb/c1 && printf 'a\\177c' > /data/qcmpb/c2 && cmp -b /data/qcmpb/b1 /data/qcmpb/b2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpb/b1 /data/qcmpb/b2 differ: byte 2, line 1 is 142 b 130 X\nrc=1\n",
"stderr": ""
},
"flags": [
"b"
]
},
{
"id": "cmp_b_renders_control_bytes_cat_v_style",
"seq": 601137,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -b /data/qcmpb/c1 /data/qcmpb/c2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpb/c1 /data/qcmpb/c2 differ: byte 2, line 1 is 1 ^A 177 ^?\nrc=1\n",
"stderr": ""
},
"flags": [
"b"
]
},
{
"id": "cmp_bl_adds_a_character_column_to_each_octal",
"seq": 601138,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -bl /data/qcmpb/b1 /data/qcmpb/b2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "2 142 b 130 X\nrc=1\n",
"stderr": ""
},
"flags": [
"b",
"l"
]
},
{
"id": "cmp_bl_pads_the_octal_of_a_control_byte",
"seq": 601139,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -bl /data/qcmpb/c1 /data/qcmpb/c2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "2 1 ^A 177 ^?\nrc=1\n",
"stderr": ""
},
"flags": [
"b",
"l"
]
}
]
}
+239
View File
@@ -0,0 +1,239 @@
{
"cases": [
{
"id": "cmp_i_skips_both_files",
"seq": 601140,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qcmpi && printf 'abcdefgh' > /data/qcmpi/i1 && printf 'abcXefgh' > /data/qcmpi/i2 && cmp -i 3 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 1, line 1\nrc=1\n",
"stderr": ""
},
"flags": [
"i"
]
},
{
"id": "cmp_i_pair_skips_one_count_per_file",
"seq": 601141,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -i 0:3 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 1, line 1\nrc=1\n",
"stderr": ""
},
"flags": [
"i"
]
},
{
"id": "cmp_i_equal_skips_realign_the_files",
"seq": 601142,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -i 1:1 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpi/i1 /data/qcmpi/i2 differ: char 3, line 1\nrc=1\n",
"stderr": ""
},
"flags": [
"i"
]
},
{
"id": "cmp_i_past_both_files_compares_nothing",
"seq": 601143,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -i 99 /data/qcmpi/i1 /data/qcmpi/i2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "rc=0\n",
"stderr": ""
},
"flags": [
"i"
]
},
{
"id": "cmp_i_junk_is_a_usage_error",
"seq": 601144,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -i abc /data/qcmpi/i1 /data/qcmpi/i2 2>&1; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "cmp: invalid --ignore-initial value 'abc'\nTry 'cmp --help' for more information.\nrc=2\n",
"stderr": ""
},
"flags": [
"i"
]
},
{
"id": "cmp_eof_is_a_stderr_diagnostic",
"seq": 601145,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qcmpe && printf 'ab\\nc' > /data/qcmpe/e1 && printf 'ab\\ncdef' > /data/qcmpe/e2 && cmp /data/qcmpe/e1 /data/qcmpe/e2 2>&1; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "cmp: EOF on /data/qcmpe/e1 after byte 4, in line 2\nrc=1\n",
"stderr": ""
}
},
{
"id": "cmp_l_eof_drops_the_line_clause",
"seq": 601146,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qcmpd && printf 'aXc' > /data/qcmpd/d1 && printf 'aYcdef' > /data/qcmpd/d2 && cmp -l /data/qcmpd/d1 /data/qcmpd/d2 2>&1; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "2 130 131\ncmp: EOF on /data/qcmpd/d1 after byte 3\nrc=1\n",
"stderr": ""
},
"flags": [
"l"
]
}
]
}
+140
View File
@@ -0,0 +1,140 @@
{
"cases": [
{
"id": "cmp_n_within_the_limit_is_equal",
"seq": 601132,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qcmpn && printf 'abcdef' > /data/qcmpn/n1 && printf 'abcXef' > /data/qcmpn/n2 && cmp -n 2 /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "rc=0\n",
"stderr": ""
},
"flags": [
"n"
]
},
{
"id": "cmp_n_past_the_difference_reports_it",
"seq": 601133,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -n 4 /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpn/n1 /data/qcmpn/n2 differ: char 4, line 1\nrc=1\n",
"stderr": ""
},
"flags": [
"n"
]
},
{
"id": "cmp_n_takes_a_size_suffix",
"seq": 601134,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -n 1K /data/qcmpn/n1 /data/qcmpn/n2; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "/data/qcmpn/n1 /data/qcmpn/n2 differ: char 4, line 1\nrc=1\n",
"stderr": ""
},
"flags": [
"n"
]
},
{
"id": "cmp_n_junk_is_a_usage_error",
"seq": 601135,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "cmp -n abc /data/qcmpn/n1 /data/qcmpn/n2 2>&1; echo rc=$?",
"expect": {
"exit": 0,
"stdout": "cmp: invalid --bytes value 'abc'\nTry 'cmp --help' for more information.\nrc=2\n",
"stderr": ""
},
"flags": [
"n"
]
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"cases": [
{
"id": "df_kilo",
"seq": 612013,
"targets": [
"ram"
],
"command": "df -k /data",
"expect": {
"exit": 0,
"stdout": "Filesystem 1K-blocks Used Available Use% Mounted on\nram - - - - /data\n",
"stderr": ""
},
"flags": [
"k"
]
},
{
"id": "df_all",
"seq": 612014,
"targets": [
"ram"
],
"command": "df -a /data",
"expect": {
"exit": 0,
"stdout": "Filesystem 1K-blocks Used Available Use% Mounted on\nram - - - - /data\n",
"stderr": ""
},
"flags": [
"a"
]
},
{
"id": "df_si_human",
"seq": 612015,
"targets": [
"ram"
],
"command": "df -H /data",
"expect": {
"exit": 0,
"stdout": "Filesystem Size Used Avail Use% Mounted on\nram - - - - /data\n",
"stderr": ""
},
"flags": [
"H"
]
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"cases": [
{
"id": "gzip_f_overwrites_an_existing_archive",
"seq": 601307,
"targets": [
"ram",
"disk",
"redis",
"opfs",
"s3",
"gridfs",
"s3-prefix",
"gridfs-prefix",
"hf",
"hf-prefix",
"dropbox",
"dropbox-root",
"onedrive",
"sharepoint",
"ssh",
"nextcloud",
"gdrive",
"gdrive-folder",
"box"
],
"command": "mkdir -p /data/qgzf && printf 'first' > /data/qgzf/g.txt && gzip -k /data/qgzf/g.txt && printf 'second' > /data/qgzf/g.txt && gzip -f /data/qgzf/g.txt && gzip -dc /data/qgzf/g.txt.gz",
"expect": {
"exit": 0,
"stdout": "second",
"stderr": ""
},
"flags": [
"f"
]
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"cases": [
{
"id": "ls_t_sorts_newest_first",
"seq": 960096,
"targets": [
"ram",
"disk",
"redis"
],
"command": "mkdir -p /data/qlst && printf 'x' > /data/qlst/old.txt && printf 'x' > /data/qlst/mid.txt && printf 'x' > /data/qlst/new.txt && touch -d '2021-01-01T00:00:00' /data/qlst/old.txt && touch -d '2022-01-01T00:00:00' /data/qlst/mid.txt && touch -d '2023-01-01T00:00:00' /data/qlst/new.txt && ls -1t /data/qlst",
"expect": {
"exit": 0,
"stdout": "new.txt\nmid.txt\nold.txt\n",
"stderr": ""
},
"flags": [
"t"
]
},
{
"id": "ls_rt_reverses_the_mtime_sort",
"seq": 960097,
"targets": [
"ram",
"disk",
"redis"
],
"command": "ls -1rt /data/qlst",
"expect": {
"exit": 0,
"stdout": "old.txt\nmid.txt\nnew.txt\n",
"stderr": ""
},
"flags": [
"r",
"t"
]
}
]
}
+126 -24
View File
@@ -1,7 +1,9 @@
import re
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from mirage.commands.builtin.utils.output import format_records
from mirage.commands.builtin.utils.size_suffix import size_suffixes
from mirage.commands.config import CommandOpts
from mirage.commands.errors import UsageError
from mirage.commands.spec import SPECS
@@ -11,16 +13,79 @@ from mirage.io.types import ByteSource, IOResult
from mirage.types import PathSpec
from mirage.utils.errors import FS_ERRORS, format_fs_error
_UNITS = size_suffixes("bkKMGTPEZY")
_TRY_HELP = "\nTry 'cmp --help' for more information."
_COUNT = re.compile(r"^([0-9]+)([A-Za-z]*)$")
def parse_count(raw: str, option: str) -> int:
"""One GNU ``cmp`` byte count: digits and an optional size suffix.
GNU reads ``-n``/``-i`` operands through xstrtoumax, so ``1K`` and
``1kB`` are accepted and anything else is a usage error naming the
long option, not a crash.
Args:
raw (str): the operand as typed.
option (str): the long option name for the diagnostic, e.g.
``--bytes``.
Raises:
UsageError: the operand is not digits plus a known suffix.
"""
match = _COUNT.match(raw)
suffix = match.group(2) if match is not None else ""
if match is None or (suffix and suffix not in _UNITS):
raise UsageError(f"cmp: invalid {option} value '{raw}'{_TRY_HELP}")
return int(match.group(1)) * (_UNITS[suffix] if suffix else 1)
def parse_skip(raw: str) -> tuple[int, int]:
"""The ``-i`` operand as one skip per file.
GNU takes ``SKIP`` for both files or ``SKIP1:SKIP2`` for one each,
so ``-i 0:3`` compares all of the first file against the fourth
byte onward of the second.
Args:
raw (str): the ``-i`` operand as typed.
"""
first, sep, second = raw.partition(":")
head = parse_count(first, "--ignore-initial")
if not sep:
return head, head
return head, parse_count(second, "--ignore-initial")
def visible(byte: int) -> str:
"""One byte rendered the way GNU ``cmp -b`` renders it.
The cat -v alphabet: a control byte becomes ``^X`` (so tab is
``^I``, unlike ``cat -v`` itself), DEL becomes ``^?``, and a high
byte becomes ``M-`` followed by the same rules on its low seven
bits.
Args:
byte (int): the byte value.
"""
if byte >= 128:
return "M-" + visible(byte - 128)
if byte == 127:
return "^?"
if byte < 32:
return f"^{chr(byte + 64)}"
return chr(byte)
async def cmp_cmd(
paths: list[PathSpec],
*,
read_bytes: Callable[..., Awaitable[bytes]],
silent: bool = False,
verbose: bool = False,
limit: int | None = None,
print_bytes: bool = False,
skip: int | None = None,
paths: list[PathSpec],
*,
read_bytes: Callable[..., Awaitable[bytes]],
silent: bool = False,
verbose: bool = False,
limit: int | None = None,
print_bytes: bool = False,
skip: tuple[int, int] = (0, 0),
) -> tuple[ByteSource | None, IOResult]:
if len(paths) > 2:
raise extra_operand_error(CommandName.CMP, paths[2].raw_path
@@ -36,9 +101,8 @@ async def cmp_cmd(
# or unreadable operand) is exit 2.
return None, IOResult(exit_code=2,
stderr=format_fs_error("cmp", exc, paths))
if skip is not None:
data1 = data1[skip:]
data2 = data2[skip:]
data1 = data1[skip[0]:]
data2 = data2[skip[1]:]
if limit is not None:
data1 = data1[:limit]
data2 = data2[:limit]
@@ -46,24 +110,62 @@ async def cmp_cmd(
return None, IOResult()
if silent:
return None, IOResult(exit_code=1)
common = min(len(data1), len(data2))
if verbose:
out_lines: list[str] = []
for idx in range(min(len(data1), len(data2))):
for idx in range(common):
if data1[idx] != data2[idx]:
out_lines.append(f"{idx + 1} {data1[idx]:o} {data2[idx]:o}")
return format_records(out_lines), IOResult(exit_code=1)
for idx in range(min(len(data1), len(data2))):
row = f"{idx + 1} {data1[idx]:>3o}"
if print_bytes:
row += f" {visible(data1[idx]):<4}"
row += f" {data2[idx]:>3o}"
if print_bytes:
row += f" {visible(data2[idx])}"
out_lines.append(row)
io = IOResult(exit_code=1)
if len(data1) != len(data2):
io.stderr = _eof_error(paths, data1, data2, verbose)
return format_records(out_lines), io
for idx in range(common):
if data1[idx] != data2[idx]:
line = 1 + data1[:idx].count(ord(b"\n"))
# GNU counts in `byte` under -b and in `char` otherwise, on
# the same offset -- the word tracks the flag, not a unit.
unit = "byte" if print_bytes else "char"
msg = (f"{p0.virtual} {p1.virtual}"
f" differ: char {idx + 1}, line {line}")
f" differ: {unit} {idx + 1}, line {line}")
if print_bytes:
msg += (f" is {data1[idx]:o} {chr(data1[idx])}"
f" {data2[idx]:o} {chr(data2[idx])}")
msg += (f" is {data1[idx]:>3o} {visible(data1[idx])}"
f" {data2[idx]:>3o} {visible(data2[idx])}")
return format_records([msg]), IOResult(exit_code=1)
shorter = p0.virtual if len(data1) < len(data2) else p1.virtual
msg = f"cmp: EOF on {shorter}"
return format_records([msg]), IOResult(exit_code=1)
return None, IOResult(exit_code=1,
stderr=_eof_error(paths, data1, data2, verbose))
def _eof_error(
paths: list[PathSpec],
data1: bytes,
data2: bytes,
verbose: bool,
) -> bytes:
"""GNU's ``EOF on FILE`` diagnostic for a common-prefix difference.
It is a diagnostic, not output: GNU writes it to stderr and still
exits 1. ``-l`` reports the byte only, every other mode adds the
line the count lands in.
Args:
paths (list[PathSpec]): the two operands, in order.
data1 (bytes): the first file's compared bytes.
data2 (bytes): the second file's compared bytes.
verbose (bool): whether ``-l`` is in effect.
"""
shorter = paths[0] if len(data1) < len(data2) else paths[1]
held = data1 if len(data1) < len(data2) else data2
msg = f"cmp: EOF on {shorter.virtual} after byte {len(held)}"
if not verbose:
msg += f", in line {1 + held.count(ord(b'\n'))}"
return (msg + "\n").encode()
__all__ = ["cmp_cmd"]
@@ -75,7 +177,7 @@ class CmpFlags:
verbose: bool = False
limit: int | None = None
print_bytes: bool = False
skip: int | None = None
skip: tuple[int, int] = (0, 0)
def parse_flags(flags: Mapping[str, FlagValue]) -> CmpFlags:
@@ -85,9 +187,9 @@ def parse_flags(flags: Mapping[str, FlagValue]) -> CmpFlags:
return CmpFlags(
silent=fl.as_bool("s"),
verbose=fl.as_bool("args_l"),
limit=int(n_raw) if n_raw is not None else None,
limit=parse_count(n_raw, "--bytes") if n_raw is not None else None,
print_bytes=fl.as_bool("b"),
skip=int(i_raw) if i_raw is not None else None,
skip=parse_skip(i_raw) if i_raw is not None else (0, 0),
)
@@ -15,10 +15,10 @@
import functools
from mirage.commands.builtin.generic.cmp import cmp_cmd as generic_cmp
from mirage.commands.builtin.generic.cmp import parse_flags
from mirage.commands.builtin.generic.crossmount.types import CrossResult
from mirage.commands.builtin.generic.crossmount.utils import flat_scopes, relay
from mirage.commands.spec import SPECS
from mirage.commands.spec.types import FlagValue, FlagView
from mirage.commands.spec.types import FlagValue
from mirage.runtime.types import DispatchFn
from mirage.types import PathSpec
@@ -27,21 +27,22 @@ async def run_cmp(scopes: list[PathSpec], flag_kwargs: dict[str, FlagValue],
dispatch: DispatchFn) -> CrossResult:
"""Byte-compare two files on different mounts via the shared generic.
Pure wiring: both sides are read through dispatch-relayed primitives.
Pure wiring: both sides are read through dispatch-relayed primitives,
and the flags go through the generic's own ``parse_flags`` -- reading
them a second time here is how the relay came to take ``-i`` as a
bare int while the generic had moved on to GNU's ``SKIP1:SKIP2``.
Args:
scopes (list[PathSpec]): The two path operands.
flag_kwargs (dict): Flags parsed against the shared cmp spec.
dispatch (DispatchFn): Workspace operation dispatcher.
"""
fl = FlagView(flag_kwargs, spec=SPECS["cmp"])
limit = fl.as_str("n")
skip = fl.as_str("i")
parsed = parse_flags(flag_kwargs)
return await generic_cmp(flat_scopes(scopes),
read_bytes=functools.partial(
relay, dispatch, "read"),
silent=fl.as_bool("s"),
verbose=fl.as_bool("args_l"),
limit=int(limit) if limit is not None else None,
print_bytes=fl.as_bool("b"),
skip=int(skip) if skip is not None else None)
silent=parsed.silent,
verbose=parsed.verbose,
limit=parsed.limit,
print_bytes=parsed.print_bytes,
skip=parsed.skip)
+335
View File
@@ -0,0 +1,335 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import functools
from typing import Any, Callable
from mirage.io import IOResult
from mirage.ops.types import SessionView
from mirage.policy import PolicyDenied
from mirage.shell.array import (array_extent, array_get, array_set,
build_assoc_literal, build_indexed_literal)
from mirage.shell.call_stack import CallStack
from mirage.shell.errors import ArithError, ExitSignal
from mirage.shell.helpers import get_text
from mirage.shell.types import NodeType as NT
from mirage.shell.variable import ShellValue, VarAttr
from mirage.shell.xtrace import trace_assignment
from mirage.types import word_text
from mirage.workspace.executor.statement import assignment_status
from mirage.workspace.expand import expand_and_classify, expand_node
from mirage.workspace.expand.globs import glob_options, resolve_globs
from mirage.workspace.expand.variable import _array_index
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.session import Session
from mirage.workspace.session.state import (deref, element_index,
session_elements, session_view,
visible_env)
from mirage.workspace.types import ExecutionNode
async def _assign_var(view: SessionView, key: str, value: ShellValue) -> None:
"""One assignment through the session door; denial is fatal.
Every assignment spelling (scalar, array literal, subscript,
append) computes its resulting value and stores through
``view.set``, so the gate and the storage invariant live in the
door, not here. Denial mirrors the readonly case: a fatal
variable-assignment error that abandons the rest of the line.
Args:
view (SessionView): the session plane's gated door.
key (str): the variable being written.
value (ShellValue): the resulting value to store.
"""
try:
await view.set(key, value)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
except ArithError as exc:
# The `-i` coercion refused the text. GNU aborts the line the
# way a bad subscript does, voicing the evaluator's own message
# after the offending value: `bash: 1+: syntax error: ...`.
err = f"bash: {exc}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
async def expand_array_items(
array_node: Any,
session: Session,
execute_fn: Callable[..., Any],
registry: MountRegistry,
namespace: Namespace,
cs: CallStack | None,
) -> list[str]:
"""Expand an array literal into its element words.
Elements behave like any other shell word list: command
substitutions word-split and globs resolve to matches
(``a=($(cmd) /data/*.txt)``), with zero-match globs kept literal.
Args:
array_node (Any): the tree-sitter ``array`` node.
session (Session): shell session.
execute_fn (Callable): workspace execute for substitutions.
registry (MountRegistry): mount registry for glob resolution.
namespace (Namespace): addressing authority holding the links.
cs (CallStack | None): function-call scope, if any.
"""
# The session plane's door, bound once for the line: every
# expansion-time write (`${X:=d}`, `$((X=5))`) lands through it,
# so a pre_session rule governs those exactly as it governs `X=d`.
view = session_view(session, registry.policies)
values = list(array_node.named_children)
classified = await expand_and_classify(values,
session,
execute_fn,
registry,
session.cwd,
cs,
view=view)
resolved = await resolve_globs(classified,
registry,
noglob=bool(
session.shell_options.get("noglob")),
links=namespace,
options=glob_options(session))
return [word_text(w) for w in resolved]
_SUBSCRIPT_LITERAL_TYPES = frozenset({NT.WORD, NT.NUMBER, NT.ERROR})
async def _subscript_key_text(
subscript_node: Any,
name: str,
session: Session,
execute_fn: Callable[..., Any],
cs: CallStack | None,
view: SessionView | None,
) -> str:
"""The expanded subscript text of one ``name[...]=`` assignment.
A purely literal subscript keeps its raw spelling, spaces included
(bash stores ``m[ k ]`` under the key ``" k "``); anything carrying
an expansion or quoting expands node by node so ``m[$k]`` and
``m["a b"]`` resolve with quote removal. The associative path uses
the result as the key verbatim; the indexed path evaluates it as
arithmetic.
Args:
subscript_node (Any): the tree-sitter ``subscript`` node.
name (str): the array variable's name, for the raw slice.
session (Session): shell session state.
execute_fn (Callable): evaluator for command substitutions.
cs (CallStack | None): shell call stack.
view (SessionView | None): the session plane's gated door.
"""
inner = [
sc for sc in subscript_node.named_children
if sc.type != NT.VARIABLE_NAME
]
raw = get_text(subscript_node)[len(name) + 1:-1]
if not inner or all(sc.type in _SUBSCRIPT_LITERAL_TYPES for sc in inner):
return raw
parts = []
for sc in inner:
parts.append(await expand_node(sc, session, execute_fn, cs, view=view))
return "".join(parts)
async def execute_assignment(
node: Any,
session: Session,
execute_fn: Callable[..., Any],
registry: MountRegistry,
namespace: Namespace,
cs: CallStack | None,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Execute one top-level variable assignment (`a=1`, `a[i]+=v`).
Every spelling -- scalar, array literal, subscript, append -- is
computed with bash's own mechanics on a copy of the held value and
then stored through the session door, which owns the admission gate
and the scalar/array invariant.
Args:
node (Any): the tree-sitter ``variable_assignment`` node.
session (Session): shell session state.
execute_fn (Callable): recursive execute for substitutions.
registry (MountRegistry): mount registry for glob resolution.
namespace (Namespace): addressing authority holding the links.
cs (CallStack | None): function-call scope, if any.
"""
text = get_text(node)
if "=" not in text:
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
sub_seq = session._cmdsub_seq
subscript_node = next(
(c for c in node.named_children if c.type == "subscript"), None)
name_source = subscript_node if subscript_node is not None else node
name_node = next(
(c for c in name_source.named_children if c.type == NT.VARIABLE_NAME),
None)
spelled = (get_text(name_node)
if name_node is not None else text.partition("=")[0])
# A name reference assigns to its target, whatever the shape of
# the assignment; an unaimed one (`declare -n r; r=v`) resolves
# to itself and takes the value as the target's name. The
# spelling is kept for slicing the subscript out of the source.
key = deref(session, spelled) or spelled
append = any(c.type == "+=" for c in node.children)
if key in session.readonly_vars:
# A bare assignment to a readonly variable is a fatal
# variable-assignment error in non-interactive bash: the
# rest of the line is abandoned (builtins like `export`
# merely fail with 1 and continue).
err = f"bash: {key}: readonly variable\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1)
val_nodes = [
c for c in node.named_children
if c.type not in (NT.VARIABLE_NAME, "subscript")
]
# Every branch below computes its resulting value with bash's
# own mechanics on a copy, then stores through the one session
# door, which owns the gate and the scalar/array invariant.
view = session_view(session, namespace.registry.policies)
if val_nodes and val_nodes[0].type == NT.ARRAY:
items = await expand_array_items(val_nodes[0], session, execute_fn,
registry, namespace, cs)
amap = session.assocs.get(key)
if amap is not None:
built, bad_words = build_assoc_literal(amap, items, append)
await _assign_var(view, key, built)
if bad_words:
err = ("\n".join(
f"bash: {key}: '{word}': must use subscript when "
"assigning associative array"
for word in bad_words) + "\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=text,
exit_code=1,
stderr=err)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
held = session.arrays.get(key)
if append and held is None:
scalar = session.env.get(key)
held = None if scalar is None else [scalar]
# `arr+=(...)` starts at the extent, so it fills the hole a
# trailing `unset arr[last]` left but skips interior ones;
# a `[i]=v` element places at i and the next plain word
# continues from there.
base = build_indexed_literal(
held, items, append,
functools.partial(element_index,
env=visible_env(session),
elements=session_elements(session)))
await _assign_var(view, key, base)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(command=text,
exit_code=code)
if val_nodes:
val = await expand_node(val_nodes[0],
session,
execute_fn,
cs,
view=view)
else:
val = text.partition("=")[2]
if subscript_node is not None:
sub_text = await _subscript_key_text(subscript_node, spelled, session,
execute_fn, cs, view)
amap = session.assocs.get(key)
raw_sub = get_text(subscript_node)[len(spelled) + 1:-1]
if not raw_sub.strip() or (amap is not None and sub_text == ""):
# bash aborts the whole line on a bad assignment
# subscript (status 1), naming the raw spelling
# (`m[$e]: bad array subscript`). An indexed subscript
# that merely *expands* empty stays legal (arithmetic
# on nothing is 0), so only the associative kind checks
# the expanded text.
name_text = text.partition("=")[0].removesuffix("+")
raise ExitSignal(1,
stderr=(f"bash: {name_text}: "
"bad array subscript\n").encode(),
contained_code=1)
if amap is not None:
# The subscript is the key: no arithmetic, `m[1+1]`
# writes the key "1+1".
new_map = dict(amap)
new_map[sub_text] = (amap.get(sub_text, "") +
val) if append else val
await _assign_var(view, key, new_map)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
arr = session.arrays.get(key)
if arr is None:
scalar = session.env.get(key)
arr = [] if scalar is None else [scalar]
else:
arr = list(arr)
idx = _array_index(sub_text, visible_env(session),
session_elements(session))
if idx < 0:
idx += array_extent(arr)
if idx < 0:
# Same fatal shape as the empty subscript above.
name_text = text.partition("=")[0].removesuffix("+")
raise ExitSignal(1,
stderr=(f"bash: {name_text}: "
"bad array subscript\n").encode(),
contained_code=1)
array_set(arr, idx, array_get(arr, idx) + val if append else val)
await _assign_var(view, key, arr)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(command=text,
exit_code=code)
held_map = session.assocs.get(key)
held_arr = session.arrays.get(key)
if held_map is not None:
# `m=x` on an associative array writes the literal key "0"
# and keeps every other key, as bash does.
new_map = dict(held_map)
new_map["0"] = (held_map.get("0", "") + val) if append else val
await _assign_var(view, key, new_map)
elif held_arr is not None:
# `a=x` writes element 0 and keeps the rest; `a+=x` appends
# onto element 0.
new_arr = list(held_arr)
array_set(new_arr, 0, (array_get(new_arr, 0) + val) if append else val)
await _assign_var(view, key, new_arr)
else:
held_var = session.vars.get(key)
if (append and held_var is not None
and VarAttr.INTEGER in held_var.attrs):
# `n+=3` on an integer name adds: the door evaluates
# `old + new`, so `declare -i n=5; n+=3` stores 8, not 53.
new_val = f"{session.env.get(key, '0')} + ({val})"
else:
new_val = session.env.get(key, "") + val if append else val
await _assign_var(view, key, new_val)
# Reassigning OPTIND (even to its current value) restarts the
# getopts scan, matching bash's internal char pointer.
if key == "OPTIND":
session._getopts_optind = None
code = assignment_status(session, sub_seq)
io = IOResult(exit_code=code)
if session.shell_options.get("xtrace"):
io.stderr = trace_assignment(key, val, append)
return None, io, ExecutionNode(command=text, exit_code=code)
+544
View File
@@ -0,0 +1,544 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
from typing import Any, Callable
from mirage.io import IOResult
from mirage.ops.types import SessionView
from mirage.policy import PolicyDenied
from mirage.shell.call_stack import CallStack
from mirage.shell.errors import ExitSignal
from mirage.shell.helpers import get_declaration_keyword, get_text
from mirage.shell.types import NodeType as NT
from mirage.shell.variable import VarAttr
from mirage.workspace.expand import expand_node
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.node.assignment import expand_array_items
from mirage.workspace.session import Session
from mirage.workspace.session.state import (ensure_var_visible, seed_var,
session_view, set_attr)
from mirage.workspace.types import ExecutionNode
from mirage.workspace.executor.builtins import ( # isort: skip
handle_declare_functions, handle_declare_print, handle_export,
handle_local, handle_readonly, note_local_array)
def _merge_conversion_errors(
result: tuple[Any, IOResult, ExecutionNode],
errors: list[str],
) -> tuple[Any, IOResult, ExecutionNode]:
"""Fold kind-conversion refusals into a declaration's result.
GNU reports `cannot convert indexed to associative array` per
refused name on stderr and fails the builtin with 1 while the other
operands still declare, so the refusals ride the handler's own
result rather than replacing it.
Args:
result (tuple): the handler's (stream, io, node) answer.
errors (list[str]): the refusal lines, in operand order.
"""
if not errors:
return result
stream, io, node = result
extra = ("\n".join(errors) + "\n").encode()
prior = io.stderr if isinstance(io.stderr, bytes) else b""
merged = prior + extra
new_io = IOResult(exit_code=1,
stderr=merged,
reads=io.reads,
writes=io.writes,
cache=io.cache)
new_node = ExecutionNode(command=node.command, exit_code=1, stderr=merged)
return stream, new_io, new_node
# Every letter GNU's `declare` accepts, so a typo refuses with the usage
# line instead of being silently dropped. `-a`/`-A` are kinds, not
# attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
# /`-I` are modes the handlers read. `-n` stores the reference and every
# reader and writer resolves through it (`deref` in `session/state`).
_DECLARE_LETTERS = frozenset("aAfFgiIlnprtux")
_DECLARE_USAGE = (
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] "
"or declare -p [-aAfFilnrtux] [name ...]")
# The stored attributes a `-letter` / `+letter` toggles.
_ATTR_LETTERS = {
"i": VarAttr.INTEGER,
"l": VarAttr.LOWER,
"u": VarAttr.UPPER,
"n": VarAttr.NAMEREF,
"t": VarAttr.TRACE,
"x": VarAttr.EXPORT,
"r": VarAttr.READONLY,
}
def _declare_option_refusal(
cmd: str,
flag_chars: set[str],
plus_chars: set[str],
session: Session,
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""The refusal a `declare` family option cluster earns, if any.
An unknown letter is GNU's `invalid option` plus the usage line,
exit 2, and it wins over every other check because bash refuses
the cluster before it looks at a single operand.
Args:
cmd (str): the builtin's own name for the diagnostic.
flag_chars (set[str]): the `-` letters, `--` excluded.
plus_chars (set[str]): the `+` letters.
session (Session): shell session state (unused today, kept so
a later check that reads it does not change the signature).
"""
bad = next((c for c in sorted(flag_chars | plus_chars)
if c not in _DECLARE_LETTERS), None)
if bad is None:
return None
sign = "-" if bad in flag_chars else "+"
err = (f"bash: {cmd}: {sign}{bad}: invalid option\n"
f"{_DECLARE_USAGE}\n").encode()
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(command=cmd,
exit_code=2,
stderr=err)
async def _plus_refusals(
cmd: str,
session: Session,
view: SessionView,
plus_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""The per-name refusals a `+letter` earns after the operands are
known.
Two letters cannot be taken off. `+r` on a readonly name is
`declare: R: readonly variable`, exit 1, and the name stays frozen.
`+a` / `+A` on an array is `cannot destroy array variables in this
way`, exit 1, since the kind is what the value is, not a mark. Both
are pinned on 5.2.37 and neither stops the other operands from
declaring; the first refusal is what the builtin reports.
Args:
cmd (str): the builtin's own name for the diagnostic.
session (Session): shell session state.
view (SessionView): the session plane's gated door.
plus_chars (set[str]): the `+` letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
"""
if not (plus_chars & {"r", "a", "A"}):
return None
names = [a.partition("=")[0] for a in assignments]
names += [name for name, _, _ in staged or []]
for name in names:
if "r" in plus_chars and view.is_readonly(name):
err = f"bash: {cmd}: {name}: readonly variable\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd,
exit_code=1,
stderr=err)
if (("a" in plus_chars and name in session.arrays)
or ("A" in plus_chars and name in session.assocs)):
err = (f"bash: {cmd}: {name}: cannot destroy array variables "
"in this way\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd,
exit_code=1,
stderr=err)
return None
async def _stamp_attrs(
session: Session,
view: SessionView,
flag_chars: set[str],
plus_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
stored: list[str],
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""Apply every `-attr` / `+attr` letter to the names a declaration
stored, on top of the export stamp.
The letters that shape a value (`-i -l -u`) are stored as
attributes and applied by the door on every *later* write, which is
GNU's rule: `v=MiXeD; declare -l v` keeps `MiXeD`, and the next
`v=ABC` stores `abc`. So this stamps and never rewrites. `-l` and
`-u` are exclusive: setting one clears the other, and a cluster
naming both (`-lu`, `-ul`) sets neither, both pinned on 5.2.37.
A `+` letter clears; `+r` is refused by the door as a readonly write
would be, in the builtin's voice.
Args:
session (Session): shell session state.
view (SessionView): the session plane's gated door.
flag_chars (set[str]): the `-` letters.
plus_chars (set[str]): the `+` letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
stored (list[str]): the names the handler actually stored.
"""
refused = await _stamp_export(session, view, flag_chars, assignments,
staged, stored)
if refused is not None:
return refused
on_attrs = [
_ATTR_LETTERS[c] for c in "ilunt"
if c in flag_chars and c not in plus_chars
]
if "l" in flag_chars and "u" in flag_chars:
on_attrs = [
a for a in on_attrs if a not in (VarAttr.LOWER, VarAttr.UPPER)
]
# `+r` is refused earlier on a readonly name and a no-op otherwise,
# so it is not an off toggle; every other stored letter clears.
off_attrs = [_ATTR_LETTERS[c] for c in "iluntx" if c in plus_chars]
if not on_attrs and not off_attrs:
return None
# Through the gated mark door for every name, covered or not: the
# handler already cleared the gate for these names, so this is one
# redundant policy call per attribute, and it keeps this stamp out
# of the ungated-write allowlist that `set_attr` sites must justify.
try:
for name in stored:
for attr in on_attrs:
await view.mark(name, attr, True)
# `-l` displaces `-u` and vice versa; the record keeps one.
if attr == VarAttr.LOWER:
await view.mark(name, VarAttr.UPPER, False)
elif attr == VarAttr.UPPER:
await view.mark(name, VarAttr.LOWER, False)
for attr in off_attrs:
await view.mark(name, attr, False)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="declare",
exit_code=1,
stderr=err)
return None
async def _stamp_export(
session: Session,
view: SessionView,
flag_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
stored: list[str],
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""Mark every name a `-x` declaration stored as exported.
`declare -x NAME` marks an existing name without touching its value
and `declare -x NAME=v` assigns then marks, so the stamp lands after
the assignment either way. Staged array literals are stamped too,
since an array is as exportable as a scalar: GNU answers
`declare -x A=(a b)` with `declare -ax A=([0]="a" [1]="b")`, and
reading only `assignments` left every `declare -x NAME=(...)`
unmarked.
Shared by the readonly and the plain declaration branch because
`declare -rx X=1` goes down the readonly one and still owes the
export attribute.
Only the names the handler reports storing are marked, and marking
is not gated on the aggregate status: a declaration keeps its valid
operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits
1 and still answers `declare -x GOOD="1"`. Reading the exit code
instead left `GOOD` unexported.
A name that carried a value went through `view.set`, so its mark
rides on that decision; a bare name did not, and on an *existing*
name the handler writes nothing at all, so the mark is the only
session write there is and has to clear `pre_session` itself.
Stamping it through `set_attr` let `declare -x AWS_TOKEN` export a
host-seeded credential the deployment had refused.
Args:
session (Session): shell session state.
view (SessionView): the session plane's gated door.
flag_chars (set[str]): the declaration's collected flag letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
stored (list[str]): the names the handler actually stored.
Returns:
A refusal result when the gate denied a mark, else None.
"""
if "x" not in flag_chars:
return None
covered = {a.partition("=")[0] for a in assignments if "=" in a}
covered |= {name for name, _, _ in staged or []}
for name in stored:
if name in covered:
set_attr(session, name, VarAttr.EXPORT)
continue
try:
await view.mark(name, VarAttr.EXPORT, True)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="declare",
exit_code=1,
stderr=err)
return None
async def execute_declaration(
node: Any,
session: Session,
execute_fn: Callable[..., Any],
registry: MountRegistry,
namespace: Namespace,
cs: CallStack | None,
view: SessionView,
) -> tuple[Any, IOResult, ExecutionNode]:
"""Execute one declaration statement (export/local/declare/readonly).
The executor only reads the operands: it expands them, sorts them
into option letters, plain names and staged array literals, then
hands the result to the builtin handler that owns the keyword. The
attribute letters (`-x`, `-i`, `-l`) are stamped afterwards through
the same gated door, so `declare -rx X=1` keeps both marks.
Args:
node (Any): the tree-sitter ``declaration_command`` node.
session (Session): shell session state.
execute_fn (Callable): recursive execute for substitutions.
registry (MountRegistry): mount registry for glob resolution.
namespace (Namespace): addressing authority holding the links.
cs (CallStack | None): function-call scope, if any.
view (SessionView): the session plane's gated door, bound once
for the line so a pre_session rule governs an
expansion-time write exactly as it governs `X=d`.
"""
keyword = get_declaration_keyword(node)
assignments = []
# Array literals are staged, not stored: `readonly -a a=(y)` on an
# already-readonly name has to fail with the old value intact.
staged: list[tuple[str, bool, list[str]]] = []
# Option words are kept verbatim, in order, so `--` survives as an
# end-of-options marker and the handlers can name the *first* bad
# option letter the way bash does.
flag_words: list[str] = []
flag_chars: set[str] = set()
plus_chars: set[str] = set()
opts_done = False
for child in node.named_children:
if child.type == NT.VARIABLE_ASSIGNMENT:
val_nodes = [
c for c in child.named_children if c.type != NT.VARIABLE_NAME
]
if val_nodes and val_nodes[0].type == NT.ARRAY:
key = get_text(child).partition("=")[0]
items = await expand_array_items(val_nodes[0], session,
execute_fn, registry,
namespace, cs)
staged.append(
(key.removesuffix("+"), key.endswith("+"), items))
continue
expanded = await expand_node(child,
session,
execute_fn,
cs,
view=view)
assignments.append(expanded)
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
NT.CONCATENATION, NT.WORD, NT.VARIABLE_NAME,
NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING,
NT.TRANSLATED_STRING):
# A bare `readonly NAME` / `export NAME` operand parses as
# a variable_name, not a word, and a quoted assignment
# (`export 'FOO=bar'`) as a plain string operand.
expanded = await expand_node(child,
session,
execute_fn,
cs,
view=view)
if not expanded and child.type in (NT.SIMPLE_EXPANSION,
NT.EXPANSION):
# An *unquoted* expansion that came back empty is
# removed by word splitting, so `export $UNSET` is a
# bare `export` and prints the listing. A quoted one
# is a real, empty operand: GNU answers both
# `export ""` and `export "$UNSET"` with
# ``export: `': not a valid identifier``, so it has
# to reach the builtin rather than vanish here.
continue
if (not opts_done and expanded.startswith("-")
and len(expanded) > 1):
flag_words.append(expanded)
if expanded == "--":
opts_done = True
else:
flag_chars.update(expanded[1:])
elif (not opts_done and expanded.startswith("+")
and len(expanded) > 1
and keyword in (NT.LOCAL, "declare", "typeset")):
# `+attr` turns an attribute off. Only the declare
# family reads it: `export +x` and `readonly +r` are
# `not a valid identifier` in GNU, so for those two
# the word falls through as an operand and refuses
# there.
plus_chars.update(expanded[1:])
else:
assignments.append(expanded)
cmd_word = "local" if keyword == NT.LOCAL else str(keyword)
if keyword in (NT.LOCAL, "declare", "typeset"):
refused = _declare_option_refusal(cmd_word, flag_chars, plus_chars,
session)
if refused is not None:
return refused
if (("f" in flag_chars or "F" in flag_chars)
and keyword in (NT.LOCAL, "declare", "typeset")):
# `-f`/`-F` select functions, not variables: `-rf` freezes,
# `-f NAME` prints the body, `-F NAME` prints the name, and
# a missing name is exit 1 without a word.
return handle_declare_functions(cmd_word, session, flag_chars,
assignments)
is_readonly = keyword == "readonly" or "r" in flag_chars
# `-l` and `-u` cannot both hold; a cluster naming both sets
# neither (pinned: `declare -lu s=aBc` prints `declare -- s`).
shaping = frozenset(_ATTR_LETTERS[c] for c in "ilu"
if c in flag_chars and c not in plus_chars)
if VarAttr.LOWER in shaping and VarAttr.UPPER in shaping:
shaping = shaping - {VarAttr.LOWER, VarAttr.UPPER}
conversion_errors: list[str] = []
if "A" in flag_chars or "a" in flag_chars:
# `declare -a NAME` / `declare -A NAME` with no value declare
# an empty array of that kind, so ${#NAME[@]} is 0 and an
# element write leaves the other slots unassigned. GNU
# refuses to convert between the two kinds and says so per
# name while the rest of the operands still declare.
want_assoc = "A" in flag_chars
for bare in assignments:
if "=" in bare:
continue
# Both branches below write array storage raw (the
# top-level one migrates an existing scalar), so a
# hidden name refuses like any assignment spelling
# before either lands.
try:
ensure_var_visible(session, bare)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
if want_assoc and bare in session.arrays:
conversion_errors.append(
f"bash: {cmd_word}: {bare}: cannot convert indexed "
"to associative array")
continue
if not want_assoc and bare in session.assocs:
conversion_errors.append(
f"bash: {cmd_word}: {bare}: cannot convert "
"associative to indexed array")
continue
if "g" not in flag_chars and note_local_array(session, bare):
# Inside a function this shadows whatever the caller
# had with a fresh empty array of the declared kind;
# `-g` declares at global scope instead.
seed_var(session, bare, {} if want_assoc else [])
elif want_assoc and bare not in session.assocs:
# At top level an existing scalar becomes the value
# at the literal key "0" (GNU allows scalar-to-
# associative conversion, unlike indexed).
scalar = session.env.get(bare)
seed_var(session, bare,
{} if scalar is None else {"0": scalar})
elif not want_assoc and bare not in session.arrays:
# At top level an existing scalar becomes element 0.
scalar = session.env.get(bare)
seed_var(session, bare, [] if scalar is None else [scalar])
# Array literals travel as data: the handler stores them through
# the session door and owns both refusal voices, so the executor
# only expands and stages.
if is_readonly:
decl_view = session_view(session, namespace.registry.policies)
stored: list[str] = []
# Only the `readonly` keyword owns -p / illegal-option
# handling; `declare -r` keeps names only.
if keyword == "readonly":
result = await handle_readonly(flag_words + assignments,
session,
decl_view,
arrays=staged,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping)
else:
result = await handle_readonly(assignments,
session,
decl_view,
arrays=staged,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping)
# `declare -rx X=1` carries both attributes: GNU prints
# `declare -rx X="1"`. Readonly answers first, so the export
# stamp has to land here too, or `-r` silently ate the `-x`.
refused = await _stamp_attrs(session, decl_view, flag_chars,
plus_chars, assignments, staged, stored)
if refused is not None:
return refused
return _merge_conversion_errors(result, conversion_errors)
# declare/typeset scope like `local` inside a function (bash
# semantics) and assign globally at top level, which is exactly
# handle_local's fallback when no function scope is active.
if keyword in (NT.LOCAL, "declare", "typeset"):
# `-p` prints rather than declares, so it is answered before
# the assignment path runs at all.
if (("p" in flag_chars or "p" in plus_chars)
and keyword in ("declare", "typeset")):
return await handle_declare_print(assignments, session)
decl_view = session_view(session, namespace.registry.policies)
stored = []
result = await handle_local(
assignments,
session,
decl_view,
arrays=staged,
# `declare`/`typeset` share this handler but have to name
# themselves in a diagnostic rather than say `local`.
cmd=cmd_word,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping,
nameref="n" in flag_chars and "n" not in plus_chars,
global_scope="g" in flag_chars)
plus_refused = await _plus_refusals(cmd_word, session, decl_view,
plus_chars, assignments, staged)
if plus_refused is not None:
return plus_refused
refused = await _stamp_attrs(session, decl_view, flag_chars,
plus_chars, assignments, staged, stored)
if refused is not None:
return refused
return _merge_conversion_errors(result, conversion_errors)
# Pass export flags through so -p / bare print and bad options work.
result = await handle_export(flag_words + assignments,
session,
session_view(session,
namespace.registry.policies),
arrays=staged)
return _merge_conversion_errors(result, conversion_errors)
+14 -771
View File
@@ -13,7 +13,6 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import asyncio
import functools
from functools import partial
from typing import Any, Callable
@@ -24,21 +23,17 @@ from mirage.policy import PolicyDenied
from mirage.runtime.policy import PolicyDecision
from mirage.runtime.types import DispatchFn
from mirage.shell.arith import evaluate_arith
from mirage.shell.array import (array_extent, array_get, array_set,
build_assoc_literal, build_indexed_literal)
from mirage.shell.barrier import BarrierPolicy, apply_barrier
from mirage.shell.call_stack import CallStack
from mirage.shell.console import Channel, JobConsole
from mirage.shell.errors import ArithError, ExitSignal, ReadonlyError
from mirage.shell.errors import ArithError, ReadonlyError
from mirage.shell.job_table import JobTable
from mirage.shell.node_kind import NodeKind, node_kind
from mirage.shell.types import ERREXIT_EXEMPT_TYPES
from mirage.shell.types import NodeType as NT
from mirage.shell.types import Redirect, RedirectKind
from mirage.shell.variable import ShellValue, VarAttr
from mirage.shell.xtrace import trace_assignment
from mirage.types import word_text
from mirage.workspace.abort import MirageAbortError
from mirage.workspace.executor.builtins import handle_test, handle_unset
from mirage.workspace.executor.builtins.exec_cmd import install_exec_redirects
from mirage.workspace.executor.control import (handle_case, handle_cfor,
handle_for, handle_if,
@@ -55,56 +50,26 @@ from mirage.workspace.expand import (expand_and_classify, expand_node,
from mirage.workspace.expand.globs import glob_options, resolve_globs
from mirage.workspace.expand.node import expand_arith
from mirage.workspace.expand.pattern import expand_pattern
from mirage.workspace.expand.variable import _array_index
from mirage.workspace.mount import MountRegistry
from mirage.workspace.mount.namespace import Namespace
from mirage.workspace.node.assignment import execute_assignment
from mirage.workspace.node.command_dispatch import execute_command
from mirage.workspace.node.declaration import execute_declaration
from mirage.workspace.node.program import execute_program
from mirage.workspace.node.test_expr import (expand_double_bracket,
expand_test_expr)
from mirage.workspace.session import Session
from mirage.workspace.session.elements import assign_element
from mirage.workspace.session.state import (deref, element_index,
ensure_var_visible, seed_var,
from mirage.workspace.session.state import (ensure_var_visible,
session_elements, session_view,
set_attr, visible_env)
visible_env)
from mirage.workspace.types import ExecutionNode
from mirage.shell.helpers import ( # isort: skip
get_case_items, get_case_word, get_cfor_parts, get_declaration_keyword,
get_for_parts, get_function_body, get_function_name, get_if_branches,
get_list_parts, get_negated_command, get_pipeline_commands, get_redirects,
get_text, get_unset_args, get_while_parts)
from mirage.workspace.executor.builtins import ( # isort: skip
handle_declare_functions, handle_declare_print, handle_export,
handle_local, handle_readonly, handle_test, handle_unset, note_local_array)
async def _assign_var(view: SessionView, key: str, value: ShellValue) -> None:
"""One assignment through the session door; denial is fatal.
Every assignment spelling (scalar, array literal, subscript,
append) computes its resulting value and stores through
``view.set``, so the gate and the storage invariant live in the
door, not here. Denial mirrors the readonly case: a fatal
variable-assignment error that abandons the rest of the line.
Args:
view (SessionView): the session plane's gated door.
key (str): the variable being written.
value (ShellValue): the resulting value to store.
"""
try:
await view.set(key, value)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
except ArithError as exc:
# The `-i` coercion refused the text. GNU aborts the line the
# way a bad subscript does, voicing the evaluator's own message
# after the offending value: `bash: 1+: syntax error: ...`.
err = f"bash: {exc}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
get_case_items, get_case_word, get_cfor_parts, get_for_parts,
get_function_body, get_function_name, get_if_branches, get_list_parts,
get_negated_command, get_pipeline_commands, get_redirects, get_text,
get_unset_args, get_while_parts)
async def _eval_cfor_expr(
@@ -177,49 +142,6 @@ STREAMING_KINDS = frozenset({
})
async def _expand_array_items(
array_node: Any,
session: Session,
execute_fn: Callable[..., Any],
registry: MountRegistry,
namespace: Namespace,
cs: CallStack | None,
) -> list[str]:
"""Expand an array literal into its element words.
Elements behave like any other shell word list: command
substitutions word-split and globs resolve to matches
(``a=($(cmd) /data/*.txt)``), with zero-match globs kept literal.
Args:
array_node (Any): the tree-sitter ``array`` node.
session (Session): shell session.
execute_fn (Callable): workspace execute for substitutions.
registry (MountRegistry): mount registry for glob resolution.
namespace (Namespace): addressing authority holding the links.
cs (CallStack | None): function-call scope, if any.
"""
# The session plane's door, bound once for the line: every
# expansion-time write (`${X:=d}`, `$((X=5))`) lands through it,
# so a pre_session rule governs those exactly as it governs `X=d`.
view = session_view(session, registry.policies)
values = list(array_node.named_children)
classified = await expand_and_classify(values,
session,
execute_fn,
registry,
session.cwd,
cs,
view=view)
resolved = await resolve_globs(classified,
registry,
noglob=bool(
session.shell_options.get("noglob")),
links=namespace,
options=glob_options(session))
return [word_text(w) for w in resolved]
async def _recurse_reassociated(
recurse: Callable[..., Any],
dispatch: DispatchFn,
@@ -310,316 +232,6 @@ async def _recurse_pipe_stderr(
return stdout, io, exec_node
_SUBSCRIPT_LITERAL_TYPES = frozenset({NT.WORD, NT.NUMBER, NT.ERROR})
async def _subscript_key_text(
subscript_node: Any,
name: str,
session: Session,
execute_fn: Callable[..., Any],
cs: CallStack | None,
view: SessionView | None,
) -> str:
"""The expanded subscript text of one ``name[...]=`` assignment.
A purely literal subscript keeps its raw spelling, spaces included
(bash stores ``m[ k ]`` under the key ``" k "``); anything carrying
an expansion or quoting expands node by node so ``m[$k]`` and
``m["a b"]`` resolve with quote removal. The associative path uses
the result as the key verbatim; the indexed path evaluates it as
arithmetic.
Args:
subscript_node (Any): the tree-sitter ``subscript`` node.
name (str): the array variable's name, for the raw slice.
session (Session): shell session state.
execute_fn (Callable): evaluator for command substitutions.
cs (CallStack | None): shell call stack.
view (SessionView | None): the session plane's gated door.
"""
inner = [
sc for sc in subscript_node.named_children
if sc.type != NT.VARIABLE_NAME
]
raw = get_text(subscript_node)[len(name) + 1:-1]
if not inner or all(sc.type in _SUBSCRIPT_LITERAL_TYPES for sc in inner):
return raw
parts = []
for sc in inner:
parts.append(await expand_node(sc, session, execute_fn, cs, view=view))
return "".join(parts)
def _merge_conversion_errors(
result: tuple[Any, IOResult, ExecutionNode],
errors: list[str],
) -> tuple[Any, IOResult, ExecutionNode]:
"""Fold kind-conversion refusals into a declaration's result.
GNU reports `cannot convert indexed to associative array` per
refused name on stderr and fails the builtin with 1 while the other
operands still declare, so the refusals ride the handler's own
result rather than replacing it.
Args:
result (tuple): the handler's (stream, io, node) answer.
errors (list[str]): the refusal lines, in operand order.
"""
if not errors:
return result
stream, io, node = result
extra = ("\n".join(errors) + "\n").encode()
prior = io.stderr if isinstance(io.stderr, bytes) else b""
merged = prior + extra
new_io = IOResult(exit_code=1,
stderr=merged,
reads=io.reads,
writes=io.writes,
cache=io.cache)
new_node = ExecutionNode(command=node.command, exit_code=1, stderr=merged)
return stream, new_io, new_node
# Every letter GNU's `declare` accepts, so a typo refuses with the usage
# line instead of being silently dropped. `-a`/`-A` are kinds, not
# attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
# /`-I` are modes the handlers read. `-n` stores the reference and every
# reader and writer resolves through it (`deref` in `session/state`).
_DECLARE_LETTERS = frozenset("aAfFgiIlnprtux")
_DECLARE_USAGE = (
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] "
"or declare -p [-aAfFilnrtux] [name ...]")
# The stored attributes a `-letter` / `+letter` toggles.
_ATTR_LETTERS = {
"i": VarAttr.INTEGER,
"l": VarAttr.LOWER,
"u": VarAttr.UPPER,
"n": VarAttr.NAMEREF,
"t": VarAttr.TRACE,
"x": VarAttr.EXPORT,
"r": VarAttr.READONLY,
}
def _declare_option_refusal(
cmd: str,
flag_chars: set[str],
plus_chars: set[str],
session: Session,
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""The refusal a `declare` family option cluster earns, if any.
An unknown letter is GNU's `invalid option` plus the usage line,
exit 2, and it wins over every other check because bash refuses
the cluster before it looks at a single operand.
Args:
cmd (str): the builtin's own name for the diagnostic.
flag_chars (set[str]): the `-` letters, `--` excluded.
plus_chars (set[str]): the `+` letters.
session (Session): shell session state (unused today, kept so
a later check that reads it does not change the signature).
"""
bad = next((c for c in sorted(flag_chars | plus_chars)
if c not in _DECLARE_LETTERS), None)
if bad is None:
return None
sign = "-" if bad in flag_chars else "+"
err = (f"bash: {cmd}: {sign}{bad}: invalid option\n"
f"{_DECLARE_USAGE}\n").encode()
return None, IOResult(exit_code=2, stderr=err), ExecutionNode(command=cmd,
exit_code=2,
stderr=err)
async def _plus_refusals(
cmd: str,
session: Session,
view: SessionView,
plus_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""The per-name refusals a `+letter` earns after the operands are
known.
Two letters cannot be taken off. `+r` on a readonly name is
`declare: R: readonly variable`, exit 1, and the name stays frozen.
`+a` / `+A` on an array is `cannot destroy array variables in this
way`, exit 1, since the kind is what the value is, not a mark. Both
are pinned on 5.2.37 and neither stops the other operands from
declaring; the first refusal is what the builtin reports.
Args:
cmd (str): the builtin's own name for the diagnostic.
session (Session): shell session state.
view (SessionView): the session plane's gated door.
plus_chars (set[str]): the `+` letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
"""
if not (plus_chars & {"r", "a", "A"}):
return None
names = [a.partition("=")[0] for a in assignments]
names += [name for name, _, _ in staged or []]
for name in names:
if "r" in plus_chars and view.is_readonly(name):
err = f"bash: {cmd}: {name}: readonly variable\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd,
exit_code=1,
stderr=err)
if (("a" in plus_chars and name in session.arrays)
or ("A" in plus_chars and name in session.assocs)):
err = (f"bash: {cmd}: {name}: cannot destroy array variables "
"in this way\n").encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command=cmd,
exit_code=1,
stderr=err)
return None
async def _stamp_attrs(
session: Session,
view: SessionView,
flag_chars: set[str],
plus_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
stored: list[str],
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""Apply every `-attr` / `+attr` letter to the names a declaration
stored, on top of the export stamp.
The letters that shape a value (`-i -l -u`) are stored as
attributes and applied by the door on every *later* write, which is
GNU's rule: `v=MiXeD; declare -l v` keeps `MiXeD`, and the next
`v=ABC` stores `abc`. So this stamps and never rewrites. `-l` and
`-u` are exclusive: setting one clears the other, and a cluster
naming both (`-lu`, `-ul`) sets neither, both pinned on 5.2.37.
A `+` letter clears; `+r` is refused by the door as a readonly write
would be, in the builtin's voice.
Args:
session (Session): shell session state.
view (SessionView): the session plane's gated door.
flag_chars (set[str]): the `-` letters.
plus_chars (set[str]): the `+` letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
stored (list[str]): the names the handler actually stored.
"""
refused = await _stamp_export(session, view, flag_chars, assignments,
staged, stored)
if refused is not None:
return refused
on_attrs = [
_ATTR_LETTERS[c] for c in "ilunt"
if c in flag_chars and c not in plus_chars
]
if "l" in flag_chars and "u" in flag_chars:
on_attrs = [
a for a in on_attrs if a not in (VarAttr.LOWER, VarAttr.UPPER)
]
# `+r` is refused earlier on a readonly name and a no-op otherwise,
# so it is not an off toggle; every other stored letter clears.
off_attrs = [_ATTR_LETTERS[c] for c in "iluntx" if c in plus_chars]
if not on_attrs and not off_attrs:
return None
# Through the gated mark door for every name, covered or not: the
# handler already cleared the gate for these names, so this is one
# redundant policy call per attribute, and it keeps this stamp out
# of the ungated-write allowlist that `set_attr` sites must justify.
try:
for name in stored:
for attr in on_attrs:
await view.mark(name, attr, True)
# `-l` displaces `-u` and vice versa; the record keeps one.
if attr == VarAttr.LOWER:
await view.mark(name, VarAttr.UPPER, False)
elif attr == VarAttr.UPPER:
await view.mark(name, VarAttr.LOWER, False)
for attr in off_attrs:
await view.mark(name, attr, False)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="declare",
exit_code=1,
stderr=err)
return None
async def _stamp_export(
session: Session,
view: SessionView,
flag_chars: set[str],
assignments: list[str],
staged: list[tuple[str, bool, list[str]]] | None,
stored: list[str],
) -> tuple[Any, IOResult, ExecutionNode] | None:
"""Mark every name a `-x` declaration stored as exported.
`declare -x NAME` marks an existing name without touching its value
and `declare -x NAME=v` assigns then marks, so the stamp lands after
the assignment either way. Staged array literals are stamped too,
since an array is as exportable as a scalar: GNU answers
`declare -x A=(a b)` with `declare -ax A=([0]="a" [1]="b")`, and
reading only `assignments` left every `declare -x NAME=(...)`
unmarked.
Shared by the readonly and the plain declaration branch because
`declare -rx X=1` goes down the readonly one and still owes the
export attribute.
Only the names the handler reports storing are marked, and marking
is not gated on the aggregate status: a declaration keeps its valid
operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits
1 and still answers `declare -x GOOD="1"`. Reading the exit code
instead left `GOOD` unexported.
A name that carried a value went through `view.set`, so its mark
rides on that decision; a bare name did not, and on an *existing*
name the handler writes nothing at all, so the mark is the only
session write there is and has to clear `pre_session` itself.
Stamping it through `set_attr` let `declare -x AWS_TOKEN` export a
host-seeded credential the deployment had refused.
Args:
session (Session): shell session state.
view (SessionView): the session plane's gated door.
flag_chars (set[str]): the declaration's collected flag letters.
assignments (list[str]): `NAME` / `NAME=value` operands.
staged (list[tuple[str, bool, list[str]]] | None): staged array
literals from the same declaration.
stored (list[str]): the names the handler actually stored.
Returns:
A refusal result when the gate denied a mark, else None.
"""
if "x" not in flag_chars:
return None
covered = {a.partition("=")[0] for a in assignments if "=" in a}
covered |= {name for name, _, _ in staged or []}
for name in stored:
if name in covered:
set_attr(session, name, VarAttr.EXPORT)
continue
try:
await view.mark(name, VarAttr.EXPORT, True)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
return None, IOResult(exit_code=1,
stderr=err), ExecutionNode(command="declare",
exit_code=1,
stderr=err)
return None
def _is_bare_exec(command: Any) -> bool:
"""Whether a redirected statement's command is a bare `exec`.
@@ -1011,219 +623,8 @@ async def execute_node(
# ── declaration (export/local/declare/readonly) ──
if kind == NodeKind.DECLARATION:
keyword = get_declaration_keyword(node)
assignments = []
# Array literals are staged, not stored: `readonly -a a=(y)` on an
# already-readonly name has to fail with the old value intact.
staged: list[tuple[str, bool, list[str]]] = []
# Option words are kept verbatim, in order, so `--` survives as an
# end-of-options marker and the handlers can name the *first* bad
# option letter the way bash does.
flag_words: list[str] = []
flag_chars: set[str] = set()
plus_chars: set[str] = set()
opts_done = False
for child in node.named_children:
if child.type == NT.VARIABLE_ASSIGNMENT:
val_nodes = [
c for c in child.named_children
if c.type != NT.VARIABLE_NAME
]
if val_nodes and val_nodes[0].type == NT.ARRAY:
key = get_text(child).partition("=")[0]
items = await _expand_array_items(val_nodes[0], session,
execute_fn, registry,
namespace, cs)
staged.append(
(key.removesuffix("+"), key.endswith("+"), items))
continue
expanded = await expand_node(child,
session,
execute_fn,
cs,
view=view)
assignments.append(expanded)
elif child.type in (NT.SIMPLE_EXPANSION, NT.EXPANSION,
NT.CONCATENATION, NT.WORD, NT.VARIABLE_NAME,
NT.STRING, NT.RAW_STRING, NT.ANSI_C_STRING,
NT.TRANSLATED_STRING):
# A bare `readonly NAME` / `export NAME` operand parses as
# a variable_name, not a word, and a quoted assignment
# (`export 'FOO=bar'`) as a plain string operand.
expanded = await expand_node(child,
session,
execute_fn,
cs,
view=view)
if not expanded and child.type in (NT.SIMPLE_EXPANSION,
NT.EXPANSION):
# An *unquoted* expansion that came back empty is
# removed by word splitting, so `export $UNSET` is a
# bare `export` and prints the listing. A quoted one
# is a real, empty operand: GNU answers both
# `export ""` and `export "$UNSET"` with
# ``export: `': not a valid identifier``, so it has
# to reach the builtin rather than vanish here.
continue
if (not opts_done and expanded.startswith("-")
and len(expanded) > 1):
flag_words.append(expanded)
if expanded == "--":
opts_done = True
else:
flag_chars.update(expanded[1:])
elif (not opts_done and expanded.startswith("+")
and len(expanded) > 1
and keyword in (NT.LOCAL, "declare", "typeset")):
# `+attr` turns an attribute off. Only the declare
# family reads it: `export +x` and `readonly +r` are
# `not a valid identifier` in GNU, so for those two
# the word falls through as an operand and refuses
# there.
plus_chars.update(expanded[1:])
else:
assignments.append(expanded)
cmd_word = "local" if keyword == NT.LOCAL else str(keyword)
if keyword in (NT.LOCAL, "declare", "typeset"):
refused = _declare_option_refusal(cmd_word, flag_chars, plus_chars,
session)
if refused is not None:
return refused
if (("f" in flag_chars or "F" in flag_chars)
and keyword in (NT.LOCAL, "declare", "typeset")):
# `-f`/`-F` select functions, not variables: `-rf` freezes,
# `-f NAME` prints the body, `-F NAME` prints the name, and
# a missing name is exit 1 without a word.
return handle_declare_functions(cmd_word, session, flag_chars,
assignments)
is_readonly = keyword == "readonly" or "r" in flag_chars
# `-l` and `-u` cannot both hold; a cluster naming both sets
# neither (pinned: `declare -lu s=aBc` prints `declare -- s`).
shaping = frozenset(_ATTR_LETTERS[c] for c in "ilu"
if c in flag_chars and c not in plus_chars)
if VarAttr.LOWER in shaping and VarAttr.UPPER in shaping:
shaping = shaping - {VarAttr.LOWER, VarAttr.UPPER}
conversion_errors: list[str] = []
if "A" in flag_chars or "a" in flag_chars:
# `declare -a NAME` / `declare -A NAME` with no value declare
# an empty array of that kind, so ${#NAME[@]} is 0 and an
# element write leaves the other slots unassigned. GNU
# refuses to convert between the two kinds and says so per
# name while the rest of the operands still declare.
want_assoc = "A" in flag_chars
for bare in assignments:
if "=" in bare:
continue
# Both branches below write array storage raw (the
# top-level one migrates an existing scalar), so a
# hidden name refuses like any assignment spelling
# before either lands.
try:
ensure_var_visible(session, bare)
except PolicyDenied as exc:
err = f"{exc.strerror}\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1) from exc
if want_assoc and bare in session.arrays:
conversion_errors.append(
f"bash: {cmd_word}: {bare}: cannot convert indexed "
"to associative array")
continue
if not want_assoc and bare in session.assocs:
conversion_errors.append(
f"bash: {cmd_word}: {bare}: cannot convert "
"associative to indexed array")
continue
if "g" not in flag_chars and note_local_array(session, bare):
# Inside a function this shadows whatever the caller
# had with a fresh empty array of the declared kind;
# `-g` declares at global scope instead.
seed_var(session, bare, {} if want_assoc else [])
elif want_assoc and bare not in session.assocs:
# At top level an existing scalar becomes the value
# at the literal key "0" (GNU allows scalar-to-
# associative conversion, unlike indexed).
scalar = session.env.get(bare)
seed_var(session, bare,
{} if scalar is None else {"0": scalar})
elif not want_assoc and bare not in session.arrays:
# At top level an existing scalar becomes element 0.
scalar = session.env.get(bare)
seed_var(session, bare, [] if scalar is None else [scalar])
# Array literals travel as data: the handler stores them through
# the session door and owns both refusal voices, so the executor
# only expands and stages.
if is_readonly:
decl_view = session_view(session, namespace.registry.policies)
stored: list[str] = []
# Only the `readonly` keyword owns -p / illegal-option
# handling; `declare -r` keeps names only.
if keyword == "readonly":
result = await handle_readonly(flag_words + assignments,
session,
decl_view,
arrays=staged,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping)
else:
result = await handle_readonly(assignments,
session,
decl_view,
arrays=staged,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping)
# `declare -rx X=1` carries both attributes: GNU prints
# `declare -rx X="1"`. Readonly answers first, so the export
# stamp has to land here too, or `-r` silently ate the `-x`.
refused = await _stamp_attrs(session, decl_view, flag_chars,
plus_chars, assignments, staged,
stored)
if refused is not None:
return refused
return _merge_conversion_errors(result, conversion_errors)
# declare/typeset scope like `local` inside a function (bash
# semantics) and assign globally at top level, which is exactly
# handle_local's fallback when no function scope is active.
if keyword in (NT.LOCAL, "declare", "typeset"):
# `-p` prints rather than declares, so it is answered before
# the assignment path runs at all.
if (("p" in flag_chars or "p" in plus_chars)
and keyword in ("declare", "typeset")):
return await handle_declare_print(assignments, session)
decl_view = session_view(session, namespace.registry.policies)
stored = []
result = await handle_local(
assignments,
session,
decl_view,
arrays=staged,
# `declare`/`typeset` share this handler but have to name
# themselves in a diagnostic rather than say `local`.
cmd=cmd_word,
stored=stored,
assoc="A" in flag_chars,
shaping=shaping,
nameref="n" in flag_chars and "n" not in plus_chars,
global_scope="g" in flag_chars)
plus_refused = await _plus_refusals(cmd_word, session, decl_view,
plus_chars, assignments,
staged)
if plus_refused is not None:
return plus_refused
refused = await _stamp_attrs(session, decl_view, flag_chars,
plus_chars, assignments, staged,
stored)
if refused is not None:
return refused
return _merge_conversion_errors(result, conversion_errors)
# Pass export flags through so -p / bare print and bad options work.
result = await handle_export(flag_words + assignments,
session,
session_view(session,
namespace.registry.policies),
arrays=staged)
return _merge_conversion_errors(result, conversion_errors)
return await execute_declaration(node, session, execute_fn, registry,
namespace, cs, view)
# ── unset ───────────────────────────────────
if kind == NodeKind.UNSET:
@@ -1276,166 +677,8 @@ async def execute_node(
# ── variable assignment at top level ────────
if kind == NodeKind.VAR_ASSIGN:
text = get_text(node)
if "=" not in text:
return None, IOResult(), ExecutionNode(command=text, exit_code=0)
sub_seq = session._cmdsub_seq
subscript_node = next(
(c for c in node.named_children if c.type == "subscript"), None)
name_source = subscript_node if subscript_node is not None else node
name_node = next((c for c in name_source.named_children
if c.type == NT.VARIABLE_NAME), None)
spelled = (get_text(name_node)
if name_node is not None else text.partition("=")[0])
# A name reference assigns to its target, whatever the shape of
# the assignment; an unaimed one (`declare -n r; r=v`) resolves
# to itself and takes the value as the target's name. The
# spelling is kept for slicing the subscript out of the source.
key = deref(session, spelled) or spelled
append = any(c.type == "+=" for c in node.children)
if key in session.readonly_vars:
# A bare assignment to a readonly variable is a fatal
# variable-assignment error in non-interactive bash: the
# rest of the line is abandoned (builtins like `export`
# merely fail with 1 and continue).
err = f"bash: {key}: readonly variable\n".encode()
raise ExitSignal(1, stderr=err, contained_code=1)
val_nodes = [
c for c in node.named_children
if c.type not in (NT.VARIABLE_NAME, "subscript")
]
# Every branch below computes its resulting value with bash's
# own mechanics on a copy, then stores through the one session
# door, which owns the gate and the scalar/array invariant.
view = session_view(session, namespace.registry.policies)
if val_nodes and val_nodes[0].type == NT.ARRAY:
items = await _expand_array_items(val_nodes[0], session,
execute_fn, registry, namespace,
cs)
amap = session.assocs.get(key)
if amap is not None:
built, bad_words = build_assoc_literal(amap, items, append)
await _assign_var(view, key, built)
if bad_words:
err = ("\n".join(
f"bash: {key}: '{word}': must use subscript when "
"assigning associative array"
for word in bad_words) + "\n").encode()
return None, IOResult(
exit_code=1, stderr=err), ExecutionNode(command=text,
exit_code=1,
stderr=err)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
held = session.arrays.get(key)
if append and held is None:
scalar = session.env.get(key)
held = None if scalar is None else [scalar]
# `arr+=(...)` starts at the extent, so it fills the hole a
# trailing `unset arr[last]` left but skips interior ones;
# a `[i]=v` element places at i and the next plain word
# continues from there.
base = build_indexed_literal(
held, items, append,
functools.partial(element_index,
env=visible_env(session),
elements=session_elements(session)))
await _assign_var(view, key, base)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
if val_nodes:
val = await expand_node(val_nodes[0],
session,
execute_fn,
cs,
view=view)
else:
val = text.partition("=")[2]
if subscript_node is not None:
sub_text = await _subscript_key_text(subscript_node, spelled,
session, execute_fn, cs, view)
amap = session.assocs.get(key)
raw_sub = get_text(subscript_node)[len(spelled) + 1:-1]
if not raw_sub.strip() or (amap is not None and sub_text == ""):
# bash aborts the whole line on a bad assignment
# subscript (status 1), naming the raw spelling
# (`m[$e]: bad array subscript`). An indexed subscript
# that merely *expands* empty stays legal (arithmetic
# on nothing is 0), so only the associative kind checks
# the expanded text.
name_text = text.partition("=")[0].removesuffix("+")
raise ExitSignal(1,
stderr=(f"bash: {name_text}: "
"bad array subscript\n").encode(),
contained_code=1)
if amap is not None:
# The subscript is the key: no arithmetic, `m[1+1]`
# writes the key "1+1".
new_map = dict(amap)
new_map[sub_text] = (amap.get(sub_text, "") +
val) if append else val
await _assign_var(view, key, new_map)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
arr = session.arrays.get(key)
if arr is None:
scalar = session.env.get(key)
arr = [] if scalar is None else [scalar]
else:
arr = list(arr)
idx = _array_index(sub_text, visible_env(session),
session_elements(session))
if idx < 0:
idx += array_extent(arr)
if idx < 0:
# Same fatal shape as the empty subscript above.
name_text = text.partition("=")[0].removesuffix("+")
raise ExitSignal(1,
stderr=(f"bash: {name_text}: "
"bad array subscript\n").encode(),
contained_code=1)
array_set(arr, idx, array_get(arr, idx) + val if append else val)
await _assign_var(view, key, arr)
code = assignment_status(session, sub_seq)
return None, IOResult(exit_code=code), ExecutionNode(
command=text, exit_code=code)
held_map = session.assocs.get(key)
held_arr = session.arrays.get(key)
if held_map is not None:
# `m=x` on an associative array writes the literal key "0"
# and keeps every other key, as bash does.
new_map = dict(held_map)
new_map["0"] = (held_map.get("0", "") + val) if append else val
await _assign_var(view, key, new_map)
elif held_arr is not None:
# `a=x` writes element 0 and keeps the rest; `a+=x` appends
# onto element 0.
new_arr = list(held_arr)
array_set(new_arr, 0,
(array_get(new_arr, 0) + val) if append else val)
await _assign_var(view, key, new_arr)
else:
held_var = session.vars.get(key)
if (append and held_var is not None
and VarAttr.INTEGER in held_var.attrs):
# `n+=3` on an integer name adds: the door evaluates
# `old + new`, so `declare -i n=5; n+=3` stores 8, not 53.
new_val = f"{session.env.get(key, '0')} + ({val})"
else:
new_val = session.env.get(key, "") + val if append else val
await _assign_var(view, key, new_val)
# Reassigning OPTIND (even to its current value) restarts the
# getopts scan, matching bash's internal char pointer.
if key == "OPTIND":
session._getopts_optind = None
code = assignment_status(session, sub_seq)
io = IOResult(exit_code=code)
if session.shell_options.get("xtrace"):
io.stderr = trace_assignment(key, val, append)
return None, io, ExecutionNode(command=text, exit_code=code)
return await execute_assignment(node, session, execute_fn, registry,
namespace, cs)
# ── assignment-only statement (a=1 b=2) ─────
if kind == NodeKind.VAR_ASSIGNS:
@@ -0,0 +1,136 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
"""cmp's byte counts, byte rendering and diagnostics, pinned on GNU 9.1."""
import pytest
from mirage.commands.builtin.generic.cmp import (cmp_cmd, parse_count,
parse_skip, visible)
from mirage.commands.errors import UsageError
from mirage.io.stream import materialize
from mirage.types import PathSpec
P1 = PathSpec.from_str_path("/F/one", "")
P2 = PathSpec.from_str_path("/F/two", "")
def _reader(first: bytes, second: bytes):
async def read_bytes(path: PathSpec) -> bytes:
return first if path.virtual == P1.virtual else second
return read_bytes
async def _run(first: bytes, second: bytes, **kwargs):
src, io = await cmp_cmd([P1, P2],
read_bytes=_reader(first, second),
**kwargs)
out = b"" if src is None else await materialize(src)
return out.decode(), (io.stderr or b"").decode(), io.exit_code
def test_parse_count_takes_digits_and_gnu_size_suffixes():
assert parse_count("4", "--bytes") == 4
assert parse_count("1K", "--bytes") == 1024
assert parse_count("1kB", "--bytes") == 1000
assert parse_count("1b", "--bytes") == 512
def test_parse_count_names_the_long_option_it_was_given():
# GNU says `invalid --bytes value` for -n and `invalid
# --ignore-initial value` for -i, with the Try-help line, exit 2.
with pytest.raises(UsageError) as excinfo:
parse_count("abc", "--bytes")
assert str(excinfo.value) == ("cmp: invalid --bytes value 'abc'\n"
"Try 'cmp --help' for more information.")
assert excinfo.value.exit_code == 2
def test_parse_count_rejects_an_unknown_suffix():
with pytest.raises(UsageError):
parse_count("1Q", "--bytes")
def test_parse_skip_takes_one_count_for_both_files():
assert parse_skip("3") == (3, 3)
def test_parse_skip_takes_a_colon_pair_for_one_each():
assert parse_skip("0:3") == (0, 3)
assert parse_skip("1K:2") == (1024, 2)
@pytest.mark.parametrize("byte,rendered", [
(ord("b"), "b"),
(9, "^I"),
(1, "^A"),
(127, "^?"),
(0xC3, "M-C"),
(0xA9, "M-)"),
(0x80, "M-^@"),
])
def test_visible_renders_one_byte_the_cat_v_way(byte, rendered):
assert visible(byte) == rendered
@pytest.mark.asyncio
async def test_print_bytes_switches_the_word_to_byte():
# GNU counts in `byte` under -b and in `char` otherwise.
plain, _, _ = await _run(b"abc", b"aXc")
tagged, _, _ = await _run(b"abc", b"aXc", print_bytes=True)
assert plain == "/F/one /F/two differ: char 2, line 1\n"
assert tagged == ("/F/one /F/two differ: byte 2, line 1"
" is 142 b 130 X\n")
@pytest.mark.asyncio
async def test_verbose_pads_the_octal_to_three_columns():
out, _, _ = await _run(b"a\x01c", b"a\x7fc", verbose=True)
assert out == "2 1 177\n"
@pytest.mark.asyncio
async def test_verbose_with_print_bytes_adds_a_four_wide_char_column():
out, _, _ = await _run(b"abc", b"aXc", verbose=True, print_bytes=True)
assert out == "2 142 b 130 X\n"
@pytest.mark.asyncio
async def test_skip_is_applied_per_file():
# `-i 0:3` keeps all of the first file and drops three bytes of the
# second, so the very first compared byte differs.
out, _, code = await _run(b"abcdefgh", b"abcXefgh", skip=(0, 3))
assert out == "/F/one /F/two differ: char 1, line 1\n"
assert code == 1
@pytest.mark.asyncio
async def test_eof_is_a_stderr_diagnostic_naming_the_byte_and_line():
out, err, code = await _run(b"ab\nc", b"ab\ncdef")
assert out == ""
assert err == "cmp: EOF on /F/one after byte 4, in line 2\n"
assert code == 1
@pytest.mark.asyncio
async def test_verbose_eof_reports_the_byte_without_the_line():
out, err, code = await _run(b"aXc", b"aYcdef", verbose=True)
assert out == "2 130 131\n"
assert err == "cmp: EOF on /F/one after byte 3\n"
assert code == 1
@pytest.mark.asyncio
async def test_a_limit_inside_the_common_prefix_reports_no_difference():
assert await _run(b"abcdef", b"abcXef", limit=2) == ("", "", 0)
@@ -187,7 +187,7 @@ async def test_cmp_verbose_lists_all_diffs():
async def test_cmp_skip_offset():
rb, _ = _make_backend({"/a.txt": b"xxhello", "/b.txt": b"yyhello"})
output, io = await cmp_cmd(
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb, skip=2)
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb, skip=(2, 2))
assert output is None
assert io.exit_code == 0
@@ -204,5 +204,8 @@ async def test_cmp_eof_on_shorter():
rb, _ = _make_backend({"/a.txt": b"abc", "/b.txt": b"abcdef"})
output, io = await cmp_cmd(
[_spec("/a.txt"), _spec("/b.txt")], read_bytes=rb)
assert output == b"cmp: EOF on /a.txt\n"
# GNU writes this to stderr, not stdout, and names the byte it
# stopped at plus the line that byte sits in.
assert output is None
assert io.stderr == b"cmp: EOF on /a.txt after byte 3, in line 1\n"
assert io.exit_code == 1
@@ -0,0 +1,113 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
"""Top-level assignment spellings, pinned against bash 5.2.37.
Scalar, array literal, subscript and append all compute their result
with bash's own mechanics and store through the one session door.
"""
import pytest
from mirage.resource.ram import RAMResource
from mirage.types import MountMode
from mirage.workspace import Workspace
def _ws() -> Workspace:
return Workspace({"data": RAMResource()}, mode=MountMode.WRITE)
async def _run(ws: Workspace, cmd: str) -> tuple[str, str, int]:
io = await ws.execute(cmd)
return (await io.stdout_str()), (await io.stderr_str()), io.exit_code
@pytest.mark.asyncio
async def test_array_literal_then_append_continues_at_the_extent():
ws = _ws()
out, _, _ = await _run(ws, "a=(x y); a+=(z); declare -p a")
assert out == 'declare -a a=([0]="x" [1]="y" [2]="z")\n'
@pytest.mark.asyncio
async def test_scalar_on_an_indexed_array_writes_element_zero():
ws = _ws()
out, _, _ = await _run(ws, "a=(x y); a=q; declare -p a")
assert out == 'declare -a a=([0]="q" [1]="y")\n'
@pytest.mark.asyncio
async def test_subscript_is_arithmetic_when_indexed():
ws = _ws()
out, _, _ = await _run(ws, "a=(x y z); a[1+1]=Q; declare -p a")
assert out == 'declare -a a=([0]="x" [1]="y" [2]="Q")\n'
@pytest.mark.asyncio
async def test_subscript_is_a_literal_key_when_associative():
ws = _ws()
out, _, _ = await _run(ws, "declare -A m; m[1+1]=v; declare -p m")
assert out == 'declare -A m=([1+1]="v" )\n'
@pytest.mark.asyncio
async def test_scalar_on_an_associative_array_writes_key_zero():
ws = _ws()
out, _, _ = await _run(ws, "declare -A m; m[k]=v; m=x; declare -p m")
assert '[0]="x"' in out and '[k]="v"' in out
@pytest.mark.asyncio
async def test_append_on_an_integer_name_adds_rather_than_concatenates():
ws = _ws()
out, _, _ = await _run(ws, "declare -i n=5; n+=3; echo $n")
assert out == "8\n"
@pytest.mark.asyncio
async def test_an_associative_subscript_that_expands_empty_aborts_the_line():
# GNU 5.2.37 names the raw spelling, not the expanded key, and the
# rest of the line is abandoned.
ws = _ws()
out, err, code = await _run(ws, "declare -A m; e=; m[$e]=v; echo REACHED")
assert out == ""
assert err == "bash: m[$e]: bad array subscript\n"
assert code == 1
@pytest.mark.asyncio
async def test_an_indexed_subscript_that_expands_empty_stays_legal():
# The asymmetry above: arithmetic on nothing is 0, so only the
# associative kind checks the expanded text.
ws = _ws()
out, _, code = await _run(ws, "a=(x y); e=; a[$e]=Q; declare -p a")
assert out == 'declare -a a=([0]="Q" [1]="y")\n'
assert code == 0
@pytest.mark.asyncio
async def test_assigning_a_readonly_name_aborts_the_line():
ws = _ws()
out, err, code = await _run(ws, "readonly r=1; r=2; echo REACHED")
assert out == ""
assert err == "bash: r: readonly variable\n"
assert code == 1
@pytest.mark.asyncio
async def test_an_assignment_only_statement_takes_the_last_substitution():
# The statement's status follows the last command substitution across
# ALL its assignments, not the last child's.
ws = _ws()
_, _, code = await _run(ws, "a=$(true) b=$(false)")
assert code == 1
@@ -0,0 +1,132 @@
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
"""export/local/declare/readonly, pinned against bash 5.2.37.
The executor only reads the operands -- option letters, plain names and
staged array literals -- and the keyword's handler owns the storing;
the attribute letters are stamped afterwards through the same door.
"""
import pytest
from mirage.resource.ram import RAMResource
from mirage.types import MountMode
from mirage.workspace import Workspace
def _ws() -> Workspace:
return Workspace({"data": RAMResource()}, mode=MountMode.WRITE)
async def _run(ws: Workspace, cmd: str) -> tuple[str, str, int]:
io = await ws.execute(cmd)
return (await io.stdout_str()), (await io.stderr_str()), io.exit_code
@pytest.mark.asyncio
async def test_an_unknown_option_letter_refuses_before_any_operand():
ws = _ws()
out, err, code = await _run(ws, "declare -q NAME")
assert out == ""
assert err == ("bash: declare: -q: invalid option\n"
"declare: usage: declare [-aAfFgiIlnrtux] [name[=value] "
"...] or declare -p [-aAfFilnrtux] [name ...]\n")
assert code == 2
@pytest.mark.asyncio
async def test_readonly_and_export_both_land_on_one_declaration():
# Readonly answers first, so the export stamp has to land in the
# readonly branch too or `-r` silently eats the `-x`.
ws = _ws()
out, _, _ = await _run(ws, "declare -rx X=1; declare -p X")
assert out == 'declare -rx X="1"\n'
@pytest.mark.asyncio
async def test_lower_and_upper_in_one_cluster_set_neither():
ws = _ws()
out, _, _ = await _run(ws, "declare -lu s=aBc; declare -p s")
assert out == 'declare -- s="aBc"\n'
@pytest.mark.asyncio
async def test_a_shaping_letter_applies_to_later_writes_not_the_held_value():
ws = _ws()
out, _, _ = await _run(
ws, "v=MiXeD; declare -l v; declare -p v; v=ABC; declare -p v")
assert out == 'declare -l v="MiXeD"\ndeclare -l v="abc"\n'
@pytest.mark.asyncio
async def test_the_two_array_kinds_refuse_to_convert():
ws = _ws()
_, err, code = await _run(ws, "declare -a a; declare -A a")
assert err == ("bash: declare: a: cannot convert indexed to "
"associative array\n")
assert code == 1
@pytest.mark.asyncio
async def test_plus_r_on_a_readonly_name_refuses_and_keeps_it_frozen():
ws = _ws()
_, err, code = await _run(ws, "readonly r=1; declare +r r")
assert err == "bash: declare: r: readonly variable\n"
assert code == 1
@pytest.mark.asyncio
async def test_plus_a_cannot_destroy_an_indexed_array():
ws = _ws()
_, err, code = await _run(ws, "a=(x); declare +a a")
assert err == ("bash: declare: a: cannot destroy array variables "
"in this way\n")
assert code == 1
@pytest.mark.asyncio
async def test_a_refused_operand_does_not_cost_its_siblings_their_marks():
# `declare -x GOOD=1 1BAD=x` exits 1 and still exports GOOD: the
# stamp reads the names the handler stored, not the exit code.
ws = _ws()
out, err, _ = await _run(ws, "declare -x GOOD=1 1BAD=x; declare -p GOOD")
assert "not a valid identifier" in err
assert out == 'declare -x GOOD="1"\n'
@pytest.mark.asyncio
async def test_an_unquoted_empty_expansion_is_removed_by_word_splitting():
# `export $UNSET` is a bare `export` and prints the listing; the
# quoted form is a real, empty operand and refuses.
ws = _ws()
_, _, bare = await _run(ws, "export $NOPE")
assert bare == 0
_, err, code = await _run(ws, 'export "$NOPE"')
assert err == "bash: export: `': not a valid identifier\n"
assert code == 1
@pytest.mark.asyncio
async def test_a_staged_array_literal_leaves_the_old_value_intact():
# Array literals are staged, not stored, so `readonly -a a=(y)` on
# an already-readonly name fails with the old value intact. GNU
# treats it as a fatal variable-assignment error, so the rest of
# that line never runs -- the value is read back on the next one.
ws = _ws()
_, err, code = await _run(
ws, "readonly -a a=(x); readonly -a a=(y); "
"echo REACHED")
assert err == "bash: a: readonly variable\n"
assert code == 1
out, _, _ = await _run(ws, "declare -p a")
assert out == 'declare -ar a=([0]="x")\n'
@@ -38,7 +38,7 @@ ALLOWED = {
("mirage/workspace/executor/builtins/vars.py", "handle_readonly"):
"the `=` branch only; `await view.set(key, val)` runs first and "
"the bare form uses `view.mark`",
("mirage/workspace/node/execute_node.py", "_stamp_export"):
("mirage/workspace/node/declaration.py", "_stamp_export"):
"the `covered` branch only, which is the names that carried a "
"value or a staged array literal; a bare name has no gated write "
"to ride on and goes through `view.mark`",
@@ -0,0 +1,153 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// cmp's byte counts, byte rendering and diagnostics, pinned on GNU 9.1.
// Mirrors python/tests/commands/builtin/generic/test_cmp.py.
import { describe, expect, it } from 'vitest'
import { cmpGeneric, parseCount, parseSkip, visible } from './cmp.ts'
import { UsageError } from '../../errors.ts'
import { PathSpec } from '../../../types.ts'
import { materialize } from '../../../io/types.ts'
import type { CommandOpts } from '../../config.ts'
const DEC = new TextDecoder()
const ENC = new TextEncoder()
const P1 = new PathSpec({ virtual: '/F/one', directory: '/F', resourcePath: 'one' })
const P2 = new PathSpec({ virtual: '/F/two', directory: '/F', resourcePath: 'two' })
function bytes(...values: number[]): Uint8Array {
return new Uint8Array(values)
}
async function run(
first: Uint8Array,
second: Uint8Array,
flags: Record<string, unknown> = {},
): Promise<{ out: string; err: string; code: number }> {
const stream = (p: PathSpec): AsyncIterable<Uint8Array> => {
const held = p.virtual === P1.virtual ? first : second
return (async function* gen() {
await Promise.resolve()
yield held
})()
}
const [src, io] = await cmpGeneric([P1, P2], { flags } as unknown as CommandOpts, stream)
return {
out: DEC.decode(await materialize(src)),
err: DEC.decode(await materialize(io.stderr)),
code: io.exitCode,
}
}
describe('parseCount', () => {
it('takes digits and GNU size suffixes', () => {
expect(parseCount('4', '--bytes')).toBe(4)
expect(parseCount('1K', '--bytes')).toBe(1024)
expect(parseCount('1kB', '--bytes')).toBe(1000)
expect(parseCount('1b', '--bytes')).toBe(512)
})
it('names the long option it was given', () => {
// GNU says `invalid --bytes value` for -n and `invalid
// --ignore-initial value` for -i, with the Try-help line, exit 2.
let caught: unknown
try {
parseCount('abc', '--bytes')
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(UsageError)
expect((caught as UsageError).message).toBe(
"cmp: invalid --bytes value 'abc'\nTry 'cmp --help' for more information.",
)
expect((caught as UsageError).exitCode).toBe(2)
})
it('rejects an unknown suffix', () => {
expect(() => parseCount('1Q', '--bytes')).toThrow(UsageError)
})
})
describe('parseSkip', () => {
it('takes one count for both files', () => {
expect(parseSkip('3')).toEqual([3, 3])
})
it('takes a colon pair for one each', () => {
expect(parseSkip('0:3')).toEqual([0, 3])
expect(parseSkip('1K:2')).toEqual([1024, 2])
})
})
describe('visible', () => {
it.each([
['b'.charCodeAt(0), 'b'],
[9, '^I'],
[1, '^A'],
[127, '^?'],
[0xc3, 'M-C'],
[0xa9, 'M-)'],
[0x80, 'M-^@'],
])('renders %i the cat -v way', (byte, rendered) => {
expect(visible(byte)).toBe(rendered)
})
})
describe('cmpGeneric', () => {
it('switches the word to byte under -b', async () => {
// GNU counts in `byte` under -b and in `char` otherwise.
const plain = await run(ENC.encode('abc'), ENC.encode('aXc'))
const tagged = await run(ENC.encode('abc'), ENC.encode('aXc'), { b: true })
expect(plain.out).toBe('/F/one /F/two differ: char 2, line 1\n')
expect(tagged.out).toBe('/F/one /F/two differ: byte 2, line 1 is 142 b 130 X\n')
})
it('pads the octal to three columns under -l', async () => {
const r = await run(bytes(97, 1, 99), bytes(97, 127, 99), { args_l: true })
expect(r.out).toBe('2 1 177\n')
})
it('adds a four-wide char column under -bl', async () => {
const r = await run(ENC.encode('abc'), ENC.encode('aXc'), { args_l: true, b: true })
expect(r.out).toBe('2 142 b 130 X\n')
})
it('applies the skip per file', async () => {
// `-i 0:3` keeps all of the first file and drops three bytes of the
// second, so the very first compared byte differs.
const r = await run(ENC.encode('abcdefgh'), ENC.encode('abcXefgh'), { i: '0:3' })
expect(r.out).toBe('/F/one /F/two differ: char 1, line 1\n')
expect(r.code).toBe(1)
})
it('reports EOF on stderr naming the byte and the line', async () => {
const r = await run(ENC.encode('ab\nc'), ENC.encode('ab\ncdef'))
expect(r.out).toBe('')
expect(r.err).toBe('cmp: EOF on /F/one after byte 4, in line 2\n')
expect(r.code).toBe(1)
})
it('drops the line clause from the EOF diagnostic under -l', async () => {
const r = await run(ENC.encode('aXc'), ENC.encode('aYcdef'), { args_l: true })
expect(r.out).toBe('2 130 131\n')
expect(r.err).toBe('cmp: EOF on /F/one after byte 3\n')
expect(r.code).toBe(1)
})
it('reports no difference for a limit inside the common prefix', async () => {
const r = await run(ENC.encode('abcdef'), ENC.encode('abcXef'), { n: '2' })
expect(r).toEqual({ out: '', err: '', code: 0 })
})
})
@@ -19,13 +19,89 @@ import type { PathSpec } from '../../../types.ts'
import type { CommandOpts } from '../../config.ts'
import { formatFsError, isFsError } from '../../../utils/errors.ts'
import { formatRecords } from '../utils/output.ts'
import { sizeSuffixes } from '../utils/size_suffix.ts'
import { extraOperandError } from '../../spec/usage.ts'
import { CommandName } from '../../spec/types.ts'
import { UsageError } from '../../errors.ts'
const ENC = new TextEncoder()
function octal(n: number): string {
return n.toString(8)
const UNITS = sizeSuffixes('bkKMGTPEZY')
const TRY_HELP = "\nTry 'cmp --help' for more information."
const COUNT = /^([0-9]+)([A-Za-z]*)$/
function octal(n: number, width = 0): string {
return n.toString(8).padStart(width)
}
/**
* One GNU `cmp` byte count: digits and an optional size suffix.
*
* GNU reads `-n`/`-i` operands through xstrtoumax, so `1K` and `1kB`
* are accepted and anything else is a usage error naming the long
* option, not a crash.
*/
export function parseCount(raw: string, option: string): number {
const match = COUNT.exec(raw)
const suffix = match?.[2] ?? ''
const unit = suffix === '' ? 1 : UNITS[suffix]
if (match === null || unit === undefined) {
throw new UsageError(`cmp: invalid ${option} value '${raw}'${TRY_HELP}`)
}
return Number(match[1]) * unit
}
/**
* The `-i` operand as one skip per file.
*
* GNU takes `SKIP` for both files or `SKIP1:SKIP2` for one each, so
* `-i 0:3` compares all of the first file against the fourth byte
* onward of the second.
*/
export function parseSkip(raw: string): [number, number] {
const cut = raw.indexOf(':')
if (cut === -1) {
const both = parseCount(raw, '--ignore-initial')
return [both, both]
}
return [
parseCount(raw.slice(0, cut), '--ignore-initial'),
parseCount(raw.slice(cut + 1), '--ignore-initial'),
]
}
/**
* One byte rendered the way GNU `cmp -b` renders it.
*
* The cat -v alphabet: a control byte becomes `^X` (so tab is `^I`,
* unlike `cat -v` itself), DEL becomes `^?`, and a high byte becomes
* `M-` followed by the same rules on its low seven bits.
*/
export function visible(byte: number): string {
if (byte >= 128) return `M-${visible(byte - 128)}`
if (byte === 127) return '^?'
if (byte < 32) return `^${String.fromCharCode(byte + 64)}`
return String.fromCharCode(byte)
}
interface CmpFlags {
readonly silent: boolean
readonly verbose: boolean
readonly limit: number | null
readonly printBytes: boolean
readonly skip: readonly [number, number]
}
function parseFlags(fl: FlagView): CmpFlags {
const nRaw = fl.asStr('n')
const iRaw = fl.asStr('i')
return {
silent: fl.asBool('s'),
verbose: fl.asBool('args_l'),
limit: nRaw === undefined ? null : parseCount(nRaw, '--bytes'),
printBytes: fl.asBool('b'),
skip: iRaw === undefined ? [0, 0] : parseSkip(iRaw),
}
}
function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
@@ -34,12 +110,37 @@ function arraysEqual(a: Uint8Array, b: Uint8Array): boolean {
return true
}
/**
* GNU's `EOF on FILE` diagnostic for a common-prefix difference.
*
* It is a diagnostic, not output: GNU writes it to stderr and still
* exits 1. `-l` reports the byte only, every other mode adds the line
* the count lands in.
*/
function eofError(
paths: PathSpec[],
data1: Uint8Array,
data2: Uint8Array,
verbose: boolean,
): Uint8Array {
const firstShorter = data1.byteLength < data2.byteLength
const shorter = firstShorter ? paths[0] : paths[1]
const held = firstShorter ? data1 : data2
let msg = `cmp: EOF on ${shorter?.virtual ?? ''} after byte ${String(held.byteLength)}`
if (!verbose) {
let lines = 1
for (const byte of held) if (byte === 0x0a) lines += 1
msg += `, in line ${String(lines)}`
}
return ENC.encode(`${msg}\n`)
}
export async function cmpGeneric(
paths: PathSpec[],
opts: CommandOpts,
stream: (p: PathSpec) => AsyncIterable<Uint8Array>,
): Promise<[ByteSource | null, IOResult]> {
const fl = new FlagView(opts.flags, specOf('cmp'))
const parsed = parseFlags(new FlagView(opts.flags, specOf('cmp')))
if (paths.length > 2) throw extraOperandError(CommandName.CMP, paths[2]?.rawPath ?? '')
if (paths.length < 2) {
return [null, new IOResult({ exitCode: 2, stderr: ENC.encode('cmp: requires two paths\n') })]
@@ -58,43 +159,50 @@ export async function cmpGeneric(
// unreadable operand) is exit 2.
return [null, new IOResult({ exitCode: 2, stderr: formatFsError('cmp', err, paths) })]
}
const skipRaw = fl.asInt('i')
if (skipRaw !== undefined) {
const skip = skipRaw
data1 = data1.slice(skip)
data2 = data2.slice(skip)
}
const limitRaw = fl.asInt('n')
if (limitRaw !== undefined) {
const limit = limitRaw
data1 = data1.slice(0, limit)
data2 = data2.slice(0, limit)
data1 = data1.slice(parsed.skip[0])
data2 = data2.slice(parsed.skip[1])
if (parsed.limit !== null) {
data1 = data1.slice(0, parsed.limit)
data2 = data2.slice(0, parsed.limit)
}
if (arraysEqual(data1, data2)) return [null, new IOResult()]
if (fl.asBool('s')) return [null, new IOResult({ exitCode: 1 })]
if (fl.asBool('args_l')) {
if (parsed.silent) return [null, new IOResult({ exitCode: 1 })]
const common = Math.min(data1.byteLength, data2.byteLength)
if (parsed.verbose) {
const outLines: string[] = []
const limit = Math.min(data1.byteLength, data2.byteLength)
for (let idx = 0; idx < limit; idx++) {
if (data1[idx] !== data2[idx]) {
outLines.push(`${String(idx + 1)} ${octal(data1[idx] ?? 0)} ${octal(data2[idx] ?? 0)}`)
}
for (let idx = 0; idx < common; idx++) {
const a = data1[idx] ?? 0
const b = data2[idx] ?? 0
if (a === b) continue
let row = `${String(idx + 1)} ${octal(a, 3)}`
if (parsed.printBytes) row += ` ${visible(a).padEnd(4)}`
row += ` ${octal(b, 3)}`
if (parsed.printBytes) row += ` ${visible(b)}`
outLines.push(row)
}
const out: ByteSource = formatRecords(outLines)
return [out, new IOResult({ exitCode: 1 })]
const io =
data1.byteLength === data2.byteLength
? new IOResult({ exitCode: 1 })
: new IOResult({ exitCode: 1, stderr: eofError(paths, data1, data2, true) })
return [formatRecords(outLines), io]
}
const limit = Math.min(data1.byteLength, data2.byteLength)
for (let idx = 0; idx < limit; idx++) {
if (data1[idx] !== data2[idx]) {
let line = 1
for (let k = 0; k < idx; k++) if (data1[k] === 0x0a) line += 1
let msg = `${p0.virtual} ${p1.virtual} differ: char ${String(idx + 1)}, line ${String(line)}`
if (fl.asBool('b')) {
msg += ` is ${octal(data1[idx] ?? 0)} ${String.fromCharCode(data1[idx] ?? 0)} ${octal(data2[idx] ?? 0)} ${String.fromCharCode(data2[idx] ?? 0)}`
}
return [formatRecords([msg]), new IOResult({ exitCode: 1 })]
for (let idx = 0; idx < common; idx++) {
const a = data1[idx] ?? 0
const b = data2[idx] ?? 0
if (a === b) continue
let line = 1
for (let k = 0; k < idx; k++) if (data1[k] === 0x0a) line += 1
// GNU counts in `byte` under -b and in `char` otherwise, on the
// same offset -- the word tracks the flag, not a unit.
const unit = parsed.printBytes ? 'byte' : 'char'
let msg = `${p0.virtual} ${p1.virtual} differ: ${unit} ${String(idx + 1)}, line ${String(line)}`
if (parsed.printBytes) {
msg += ` is ${octal(a, 3)} ${visible(a)} ${octal(b, 3)} ${visible(b)}`
}
return [formatRecords([msg]), new IOResult({ exitCode: 1 })]
}
const shorter = data1.byteLength < data2.byteLength ? p0 : p1
return [formatRecords([`cmp: EOF on ${shorter.virtual}`]), new IOResult({ exitCode: 1 })]
return [
null,
new IOResult({ exitCode: 1, stderr: eofError(paths, data1, data2, parsed.verbose) }),
]
}
@@ -17,7 +17,6 @@ import { Runtime } from './base.ts'
import {
bindCommands,
buildRuntime,
candidates,
DEFAULT_ENTRIES,
DEFAULT_PYTHON,
runtimeBindingsFor,
@@ -37,12 +36,6 @@ class FakeRuntime extends Runtime {
}
describe('runtime table', () => {
it('candidates are ordered, derived from captures', () => {
expect(candidates('python3')).toEqual([PyodideRuntime, MontyRuntime])
expect(candidates('node')).toEqual([QuickJsRuntime])
expect(candidates('grep')).toEqual([])
})
it('captures default is declared once per tier', () => {
// The head words are a language fact like `language` itself: the
// tier declares them, engines inherit, an instance still overrides.
@@ -113,11 +113,6 @@ const PYTHON_ONLY_HINTS: Record<string, string> = {
'JavaScript)',
}
/** The runtime classes that capture a command, preference order. */
export function candidates(command: string): (typeof RUNTIMES)[number][] {
return RUNTIMES.filter((cls) => cls.commands.includes(command))
}
// Every runtime is constructed the same way; config keys are checked
// inside each class (its config key list), so the entry level only
// knows the uniform options. Python gets this check for free
@@ -247,11 +247,16 @@ describe('handleCrossMount — cmp', () => {
})
it('EOF on shorter file → exit 1', async () => {
// GNU writes the EOF notice to stderr, not stdout, and names both
// the byte it stopped at and the line that byte sits in.
const d = dispatchWithContents(new TextEncoder().encode('ab'), new TextEncoder().encode('abc'))
const paths = [PathSpec.fromStrPath('/ram/a'), PathSpec.fromStrPath('/disk/b')]
const [out, io] = await handleCrossMount('cmp', paths, [], {}, d, runSingleNoop, null, 'cmp')
expect(io.exitCode).toBe(1)
expect(decode(out as Uint8Array)).toMatch(/EOF on/)
expect(out).toBeNull()
expect(decode(await materialize(io.stderr))).toBe(
'cmp: EOF on /ram/a after byte 2, in line 1\n',
)
})
it('missing operand → GNU strerror line', async () => {
@@ -0,0 +1,102 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Top-level assignment spellings, pinned against bash 5.2.37. Mirrors
// python/tests/workspace/node/test_assignment.py.
import { describe, expect, it } from 'vitest'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode } from '../../types.ts'
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
import { Workspace } from '../workspace/workspace.ts'
async function makeWs(): Promise<Workspace> {
const parser = await getTestParser()
return new Workspace(
{ '/data': new RAMResource() },
{ mode: MountMode.WRITE, shellParser: parser },
)
}
describe('executeAssignment', () => {
it('appends an array literal at the extent', async () => {
const ws = await makeWs()
const io = await ws.execute('a=(x y); a+=(z); declare -p a')
expect(stdoutStr(io)).toBe('declare -a a=([0]="x" [1]="y" [2]="z")\n')
})
it('writes element zero for a scalar on an indexed array', async () => {
const ws = await makeWs()
const io = await ws.execute('a=(x y); a=q; declare -p a')
expect(stdoutStr(io)).toBe('declare -a a=([0]="q" [1]="y")\n')
})
it('evaluates an indexed subscript as arithmetic', async () => {
const ws = await makeWs()
const io = await ws.execute('a=(x y z); a[1+1]=Q; declare -p a')
expect(stdoutStr(io)).toBe('declare -a a=([0]="x" [1]="y" [2]="Q")\n')
})
it('keeps an associative subscript as a literal key', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -A m; m[1+1]=v; declare -p m')
expect(stdoutStr(io)).toBe('declare -A m=([1+1]="v" )\n')
})
it('writes key zero for a scalar on an associative array', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -A m; m[k]=v; m=x; declare -p m')
expect(stdoutStr(io)).toContain('[0]="x"')
expect(stdoutStr(io)).toContain('[k]="v"')
})
it('adds rather than concatenates when appending to an integer name', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -i n=5; n+=3; echo $n')
expect(stdoutStr(io)).toBe('8\n')
})
it('aborts the line on an associative subscript that expands empty', async () => {
// GNU 5.2.37 names the raw spelling, not the expanded key, and the
// rest of the line is abandoned.
const ws = await makeWs()
const io = await ws.execute('declare -A m; e=; m[$e]=v; echo REACHED')
expect(stdoutStr(io)).toBe('')
expect(stderrStr(io)).toBe('bash: m[$e]: bad array subscript\n')
expect(io.exitCode).toBe(1)
})
it('keeps an indexed subscript that expands empty legal', async () => {
// The asymmetry above: arithmetic on nothing is 0, so only the
// associative kind checks the expanded text.
const ws = await makeWs()
const io = await ws.execute('a=(x y); e=; a[$e]=Q; declare -p a')
expect(stdoutStr(io)).toBe('declare -a a=([0]="Q" [1]="y")\n')
expect(io.exitCode).toBe(0)
})
it('aborts the line when assigning a readonly name', async () => {
const ws = await makeWs()
const io = await ws.execute('readonly r=1; r=2; echo REACHED')
expect(stdoutStr(io)).toBe('')
expect(stderrStr(io)).toBe('bash: r: readonly variable\n')
expect(io.exitCode).toBe(1)
})
it('takes the last substitution across every assignment of a statement', async () => {
const ws = await makeWs()
const io = await ws.execute('a=$(true) b=$(false)')
expect(io.exitCode).toBe(1)
})
})
@@ -0,0 +1,348 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { type ByteSource, IOResult } from '../../io/types.ts'
import type { CallStack } from '../../shell/call_stack.ts'
import {
type ShellArray,
arrayExtent,
arrayGet,
arraySet,
buildAssocLiteral,
buildIndexedLiteral,
} from '../../shell/array.ts'
import { ArithError, ExitSignal } from '../../shell/errors.ts'
import { getText } from '../../shell/helpers.ts'
import { NodeType as NT, type TSNodeLike } from '../../shell/types.ts'
import { type ShellValue, VarAttr } from '../../shell/variable.ts'
import { traceAssignment } from '../../shell/xtrace.ts'
import { PolicyDenied } from '../../policy/errors.ts'
import type { SessionView } from '../../ops/types.ts'
import { wordText } from '../../types.ts'
import { assignmentStatus } from '../executor/statement.ts'
import { type ExecuteFn, expandNode } from '../expand/node.ts'
import { globOptions, resolveGlobs } from '../expand/globs.ts'
import { expandAndClassify } from '../expand/parts.ts'
import { arrayIndex } from '../expand/variable.ts'
import type { Namespace } from '../mount/namespace/namespace.ts'
import type { MountRegistry } from '../mount/registry.ts'
import type { Session } from '../session/session.ts'
import { deref, elementIndex, sessionElements, sessionView, visibleEnv } from '../session/state.ts'
import { ExecutionNode } from '../types.ts'
type Result = [ByteSource | null, IOResult, ExecutionNode]
/**
* One assignment through the session door; denial is fatal.
*
* Every assignment spelling (scalar, array literal, subscript, append)
* computes its resulting value and stores through `view.set`, so the
* gate and the storage invariant live in the door, not here. Denial
* mirrors the readonly case: a fatal variable-assignment error that
* abandons the rest of the line.
*/
async function assignVar(view: SessionView, key: string, value: ShellValue): Promise<void> {
try {
await view.set(key, value)
} catch (err) {
if (err instanceof PolicyDenied) {
const denied = new TextEncoder().encode(`${err.message}\n`)
throw new ExitSignal(1, denied, null, 1)
}
if (err instanceof ArithError) {
// The `-i` coercion refused the text. GNU aborts the line the way
// a bad subscript does, in the evaluator's voice with the text led.
throw new ExitSignal(1, new TextEncoder().encode(`bash: ${err.message}\n`), null, 1)
}
throw err
}
}
// Array-literal elements behave like any other shell word list: command
// substitutions word-split and globs resolve to matches
// (`a=($(cmd) /data/*.txt)`), with zero-match globs kept literal.
export async function expandArrayItems(
arrayNode: TSNodeLike,
session: Session,
executeFn: ExecuteFn,
registry: MountRegistry,
namespace: Namespace,
callStack: CallStack | null,
): Promise<string[]> {
const classified = await expandAndClassify(
arrayNode.namedChildren,
session,
executeFn,
registry,
session.cwd,
callStack,
sessionView(session, registry.policies),
)
const resolved = await resolveGlobs(
classified,
registry,
session.shellOptions.noglob === true,
namespace,
globOptions(session),
)
return resolved.map((w) => wordText(w))
}
const SUBSCRIPT_LITERAL_TYPES: ReadonlySet<string> = new Set([NT.WORD, NT.NUMBER, NT.ERROR])
/**
* The expanded subscript text of one `name[...]=` assignment.
*
* A purely literal subscript keeps its raw spelling, spaces included
* (bash stores `m[ k ]` under the key `" k "`); anything carrying an
* expansion or quoting expands node by node so `m[$k]` and `m["a b"]`
* resolve with quote removal. The associative path uses the result as
* the key verbatim; the indexed path evaluates it as arithmetic.
*/
async function subscriptKeyText(
subscriptNode: TSNodeLike,
name: string,
session: Session,
executeFn: ExecuteFn,
callStack: CallStack | null,
view?: SessionView,
): Promise<string> {
const inner = subscriptNode.namedChildren.filter((sc) => sc.type !== NT.VARIABLE_NAME)
const raw = subscriptNode.text.slice(name.length + 1, -1)
if (inner.length === 0 || inner.every((sc) => SUBSCRIPT_LITERAL_TYPES.has(sc.type))) {
return raw
}
const parts: string[] = []
for (const sc of inner) {
parts.push(await expandNode(sc, session, executeFn, callStack, view))
}
return parts.join('')
}
/**
* Execute one top-level variable assignment (`a=1`, `a[i]+=v`).
*
* Every spelling — scalar, array literal, subscript, append — is
* computed with bash's own mechanics on a copy of the held value and
* then stored through the session door, which owns the admission gate
* and the scalar/array invariant.
*/
export async function executeAssignment(
node: TSNodeLike,
session: Session,
executeFn: ExecuteFn,
registry: MountRegistry,
namespace: Namespace,
callStack: CallStack | null,
): Promise<Result> {
const text = getText(node)
if (!text.includes('=')) {
return [null, new IOResult(), new ExecutionNode({ command: text, exitCode: 0 })]
}
const subSeq = session.cmdsubSeq
const subscriptNode = node.namedChildren.find((c) => c.type === 'subscript') ?? null
const nameSource = subscriptNode ?? node
const nameNode = nameSource.namedChildren.find((c) => c.type === NT.VARIABLE_NAME)
const eq = text.indexOf('=')
const spelled = nameNode !== undefined ? nameNode.text : text.slice(0, eq)
// A name reference assigns to its target, whatever the shape of the
// assignment; an unaimed one (`declare -n r; r=v`) resolves to itself
// and takes the value as the target's name. The spelling is kept for
// slicing the subscript out of the source.
const key = deref(session, spelled) || spelled
const append = node.children.some((c) => c.type === '+=')
if (session.readonlyVars.has(key)) {
// A bare assignment to a readonly variable is a fatal
// variable-assignment error in non-interactive bash: the rest of
// the line is abandoned (builtins like `export` merely fail with
// 1 and continue).
const err = new TextEncoder().encode(`bash: ${key}: readonly variable\n`)
throw new ExitSignal(1, err, null, 1)
}
const valNodes = node.namedChildren.filter(
(c) => c.type !== NT.VARIABLE_NAME && c.type !== 'subscript',
)
// Every branch below computes its resulting value with bash's own
// mechanics on a copy, then stores through the one session door,
// which owns the gate and the scalar/array invariant.
const view = sessionView(session, registry.policies)
const firstVal = valNodes[0]
if (firstVal?.type === NT.ARRAY) {
const items = await expandArrayItems(
firstVal,
session,
executeFn,
registry,
namespace,
callStack,
)
const heldMap = session.assocs[key]
if (heldMap !== undefined) {
const { map, badWords } = buildAssocLiteral(heldMap, items, append)
await assignVar(view, key, map)
if (badWords.length > 0) {
const errBytes = new TextEncoder().encode(
badWords
.map(
(word) =>
`bash: ${key}: '${word}': must use subscript when assigning associative array`,
)
.join('\n') + '\n',
)
return [
null,
new IOResult({ exitCode: 1, stderr: errBytes }),
new ExecutionNode({ command: text, exitCode: 1, stderr: errBytes }),
]
}
const mapCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: mapCode }),
new ExecutionNode({ command: text, exitCode: mapCode }),
]
}
let held: ShellArray | null = session.arrays[key] ?? null
if (append && held === null) {
const scalar = session.env[key]
held = scalar === undefined ? null : [scalar]
}
// `arr+=(...)` starts at the extent, so it fills the hole a
// trailing `unset arr[last]` left but skips interior ones; a
// `[i]=v` element places at i and the next plain word continues
// from there.
const base = buildIndexedLiteral(held, items, append, (sub) =>
elementIndex(sub, visibleEnv(session), sessionElements(session)),
)
await assignVar(view, key, base)
const arrCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: arrCode }),
new ExecutionNode({ command: text, exitCode: arrCode }),
]
}
let val = text.slice(eq + 1)
if (firstVal !== undefined) {
val = await expandNode(
firstVal,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
}
if (subscriptNode !== null) {
const subText = await subscriptKeyText(
subscriptNode,
spelled,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
const heldMap = session.assocs[key]
const rawSub = subscriptNode.text.slice(spelled.length + 1, -1)
if (rawSub.trim() === '' || (heldMap !== undefined && subText === '')) {
// bash aborts the whole line on a bad assignment subscript
// (status 1), naming the raw spelling (`m[$e]: bad array
// subscript`). An indexed subscript that merely *expands*
// empty stays legal (arithmetic on nothing is 0), so only the
// associative kind checks the expanded text.
const nameText = text.slice(0, eq).replace(/\+$/, '')
throw new ExitSignal(
1,
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
null,
1,
)
}
if (heldMap !== undefined) {
// The subscript is the key: no arithmetic, `m[1+1]` writes the
// key "1+1".
const newMap = { ...heldMap }
newMap[subText] = append ? (heldMap[subText] ?? '') + val : val
await assignVar(view, key, newMap)
const mapCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: mapCode }),
new ExecutionNode({ command: text, exitCode: mapCode }),
]
}
const existing = session.arrays[key]
let arr: ShellArray
if (existing === undefined) {
const scalar = session.env[key]
arr = scalar === undefined ? [] : [scalar]
} else {
arr = [...existing]
}
let idx = arrayIndex(subText, visibleEnv(session), sessionElements(session))
if (idx < 0) idx += arrayExtent(arr)
if (idx < 0) {
// Same fatal shape as the empty subscript above.
const nameText = text.slice(0, eq).replace(/\+$/, '')
throw new ExitSignal(
1,
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
null,
1,
)
}
arraySet(arr, idx, append ? arrayGet(arr, idx) + val : val)
await assignVar(view, key, arr)
const subCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: subCode }),
new ExecutionNode({ command: text, exitCode: subCode }),
]
}
const heldMap = session.assocs[key]
const heldArr = session.arrays[key]
if (heldMap !== undefined) {
// `m=x` on an associative array writes the literal key "0" and
// keeps every other key, as bash does.
const newMap = { ...heldMap }
newMap['0'] = append ? (heldMap['0'] ?? '') + val : val
await assignVar(view, key, newMap)
} else if (heldArr !== undefined) {
// `a=x` writes element 0 and keeps the rest; `a+=x` appends onto
// element 0.
const newArr = [...heldArr]
arraySet(newArr, 0, append ? arrayGet(newArr, 0) + val : val)
await assignVar(view, key, newArr)
} else {
const heldVar = session.vars[key]
let newVal: string
if (append && heldVar?.attrs.has(VarAttr.Integer) === true) {
// `n+=3` on an integer name adds: the door evaluates `old + new`,
// so `declare -i n=5; n+=3` stores 8, not 53.
newVal = `${session.env[key] ?? '0'} + (${val})`
} else {
newVal = append ? (session.env[key] ?? '') + val : val
}
await assignVar(view, key, newVal)
}
// Reassigning OPTIND (even to its current value) restarts the getopts
// scan, matching bash's internal char pointer.
if (key === 'OPTIND') session.getoptsOptind = null
const code = assignmentStatus(session, subSeq)
const assignIo = new IOResult({ exitCode: code })
if (session.shellOptions.xtrace === true) {
assignIo.stderr = traceAssignment(key, val, append)
}
return [null, assignIo, new ExecutionNode({ command: text, exitCode: code })]
}
@@ -0,0 +1,116 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// export/local/declare/readonly, pinned against bash 5.2.37. Mirrors
// python/tests/workspace/node/test_declaration.py.
import { describe, expect, it } from 'vitest'
import { RAMResource } from '../../resource/ram/ram.ts'
import { MountMode } from '../../types.ts'
import { getTestParser, stderrStr, stdoutStr } from '../fixtures/workspace_fixture.ts'
import { Workspace } from '../workspace/workspace.ts'
async function makeWs(): Promise<Workspace> {
const parser = await getTestParser()
return new Workspace(
{ '/data': new RAMResource() },
{ mode: MountMode.WRITE, shellParser: parser },
)
}
describe('executeDeclaration', () => {
it('refuses an unknown option letter before any operand', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -q NAME')
expect(stdoutStr(io)).toBe('')
expect(stderrStr(io)).toBe(
'bash: declare: -q: invalid option\n' +
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] ' +
'or declare -p [-aAfFilnrtux] [name ...]\n',
)
expect(io.exitCode).toBe(2)
})
it('lands readonly and export on one declaration', async () => {
// Readonly answers first, so the export stamp has to land in the
// readonly branch too or `-r` silently eats the `-x`.
const ws = await makeWs()
const io = await ws.execute('declare -rx X=1; declare -p X')
expect(stdoutStr(io)).toBe('declare -rx X="1"\n')
})
it('sets neither when lower and upper share a cluster', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -lu s=aBc; declare -p s')
expect(stdoutStr(io)).toBe('declare -- s="aBc"\n')
})
it('applies a shaping letter to later writes, not the held value', async () => {
const ws = await makeWs()
const io = await ws.execute('v=MiXeD; declare -l v; declare -p v; v=ABC; declare -p v')
expect(stdoutStr(io)).toBe('declare -l v="MiXeD"\ndeclare -l v="abc"\n')
})
it('refuses to convert between the two array kinds', async () => {
const ws = await makeWs()
const io = await ws.execute('declare -a a; declare -A a')
expect(stderrStr(io)).toBe('bash: declare: a: cannot convert indexed to associative array\n')
expect(io.exitCode).toBe(1)
})
it('refuses +r on a readonly name and keeps it frozen', async () => {
const ws = await makeWs()
const io = await ws.execute('readonly r=1; declare +r r')
expect(stderrStr(io)).toBe('bash: declare: r: readonly variable\n')
expect(io.exitCode).toBe(1)
})
it('cannot destroy an indexed array with +a', async () => {
const ws = await makeWs()
const io = await ws.execute('a=(x); declare +a a')
expect(stderrStr(io)).toBe('bash: declare: a: cannot destroy array variables in this way\n')
expect(io.exitCode).toBe(1)
})
it('does not cost siblings their marks when one operand refuses', async () => {
// `declare -x GOOD=1 1BAD=x` exits 1 and still exports GOOD: the
// stamp reads the names the handler stored, not the exit code.
const ws = await makeWs()
const io = await ws.execute('declare -x GOOD=1 1BAD=x; declare -p GOOD')
expect(stderrStr(io)).toContain('not a valid identifier')
expect(stdoutStr(io)).toBe('declare -x GOOD="1"\n')
})
it('drops an unquoted empty expansion by word splitting', async () => {
// `export $UNSET` is a bare `export` and prints the listing; the
// quoted form is a real, empty operand and refuses.
const ws = await makeWs()
expect((await ws.execute('export $NOPE')).exitCode).toBe(0)
const io = await ws.execute('export "$NOPE"')
expect(stderrStr(io)).toBe("bash: export: `': not a valid identifier\n")
expect(io.exitCode).toBe(1)
})
it('leaves the old value intact when a staged array literal refuses', async () => {
// Array literals are staged, not stored, so `readonly -a a=(y)` on
// an already-readonly name fails with the old value intact. GNU
// treats it as a fatal variable-assignment error, so the rest of
// that line never runs -- the value is read back on the next one.
const ws = await makeWs()
const io = await ws.execute('readonly -a a=(x); readonly -a a=(y); echo REACHED')
expect(stderrStr(io)).toBe('bash: a: readonly variable\n')
expect(io.exitCode).toBe(1)
expect(stdoutStr(await ws.execute('declare -p a'))).toBe('declare -ar a=([0]="x")\n')
})
})
@@ -0,0 +1,564 @@
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { type ByteSource, IOResult } from '../../io/types.ts'
import type { CallStack } from '../../shell/call_stack.ts'
import { ExitSignal } from '../../shell/errors.ts'
import { getDeclarationKeyword, getText } from '../../shell/helpers.ts'
import { NodeType as NT, type TSNodeLike } from '../../shell/types.ts'
import { VarAttr } from '../../shell/variable.ts'
import { PolicyDenied } from '../../policy/errors.ts'
import { compareCodePoints } from '../../utils/sort.ts'
import type { SessionView } from '../../ops/types.ts'
import {
handleDeclareFunctions,
handleDeclarePrint,
handleExport,
handleLocal,
handleReadonly,
noteLocalArray,
} from '../executor/builtins/index.ts'
import { type ExecuteFn, expandNode } from '../expand/node.ts'
import type { Namespace } from '../mount/namespace/namespace.ts'
import type { MountRegistry } from '../mount/registry.ts'
import type { Session } from '../session/session.ts'
import { ensureVarVisible, seedVar, sessionView, setAttr } from '../session/state.ts'
import { ExecutionNode } from '../types.ts'
import { expandArrayItems } from './assignment.ts'
type Result = [ByteSource | null, IOResult, ExecutionNode]
/**
* Fold kind-conversion refusals into a declaration's result.
*
* GNU reports `cannot convert indexed to associative array` per refused
* name on stderr and fails the builtin with 1 while the other operands
* still declare, so the refusals ride the handler's own result rather
* than replacing it.
*/
function mergeConversionErrors(result: Result, errors: readonly string[]): Result {
if (errors.length === 0) return result
const [stream, io, node] = result
const extra = new TextEncoder().encode(errors.join('\n') + '\n')
const prior = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0)
const merged = new Uint8Array(prior.length + extra.length)
merged.set(prior, 0)
merged.set(extra, prior.length)
const newIo = new IOResult({
exitCode: 1,
stderr: merged,
reads: io.reads,
writes: io.writes,
cache: io.cache,
})
return [stream, newIo, new ExecutionNode({ command: node.command, exitCode: 1, stderr: merged })]
}
// Every letter GNU's `declare` accepts, so a typo refuses with the usage
// line instead of being silently dropped. `-a`/`-A` are kinds, not
// attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
// /`-I` are modes the handlers read. `-n` is accepted and stored, but
// aliasing (reads and writes through the reference) is not wired: it is
// a separate seam through every expansion site, so a name carrying it
// declares and prints, and nothing more, rather than a partial alias
// that works in some spellings and not others.
// `-n` stores the reference and every reader and writer resolves through
// it (`deref` in `session/state`).
const DECLARE_LETTERS: ReadonlySet<string> = new Set('aAfFgiIlnprtux')
const DECLARE_USAGE =
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] or declare -p [-aAfFilnrtux] [name ...]'
// The stored attributes a `-letter` / `+letter` toggles.
const ATTR_LETTERS: ReadonlyMap<string, VarAttr> = new Map([
['i', VarAttr.Integer],
['l', VarAttr.Lower],
['u', VarAttr.Upper],
['n', VarAttr.Nameref],
['t', VarAttr.Trace],
['x', VarAttr.Export],
['r', VarAttr.Readonly],
])
/** The attributes the given letters name, in the order given, skipping
* letters that name none (kinds and modes are not attributes). */
function attrsFor(letters: string, has: (c: string) => boolean): VarAttr[] {
const out: VarAttr[] = []
for (const c of letters) {
const attr = ATTR_LETTERS.get(c)
if (attr !== undefined && has(c)) out.push(attr)
}
return out
}
/**
* The refusal a `declare` family option cluster earns, if any.
*
* An unknown letter is GNU's `invalid option` plus the usage line, exit
* 2, and it wins over every other check because bash refuses the
* cluster before it looks at a single operand.
*/
function declareOptionRefusal(
cmd: string,
flagChars: ReadonlySet<string>,
plusChars: ReadonlySet<string>,
): Result | null {
const bad = [...flagChars, ...plusChars]
.sort(compareCodePoints)
.find((c) => !DECLARE_LETTERS.has(c))
if (bad === undefined) return null
const sign = flagChars.has(bad) ? '-' : '+'
const err = new TextEncoder().encode(
`bash: ${cmd}: ${sign}${bad}: invalid option\n${DECLARE_USAGE}\n`,
)
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 2, stderr: err }),
]
}
/**
* The per-name refusals a `+letter` earns after the operands are known.
*
* Two letters cannot be taken off. `+r` on a readonly name is
* `declare: R: readonly variable`, exit 1, and the name stays frozen.
* `+a` / `+A` on an array is `cannot destroy array variables in this
* way`, exit 1, since the kind is what the value is, not a mark. Both
* are pinned on 5.2.37 and neither stops the other operands from
* declaring; the first refusal is what the builtin reports.
*/
function plusRefusals(
cmd: string,
session: Session,
view: SessionView,
plusChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
): Result | null {
if (!plusChars.has('r') && !plusChars.has('a') && !plusChars.has('A')) return null
const names = assignments.map((a) => a.split('=')[0] ?? a)
for (const { name } of staged ?? []) names.push(name)
for (const name of names) {
if (plusChars.has('r') && view.isReadonly(name)) {
const err = new TextEncoder().encode(`bash: ${cmd}: ${name}: readonly variable\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
]
}
if (
(plusChars.has('a') && Object.hasOwn(session.arrays, name)) ||
(plusChars.has('A') && Object.hasOwn(session.assocs, name))
) {
const err = new TextEncoder().encode(
`bash: ${cmd}: ${name}: cannot destroy array variables in this way\n`,
)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
]
}
}
return null
}
/**
* Apply every `-attr` / `+attr` letter to the names a declaration
* stored, on top of the export stamp.
*
* The letters that shape a value (`-i -l -u`) are stored as attributes
* and applied by the door on every *later* write, which is GNU's rule:
* `v=MiXeD; declare -l v` keeps `MiXeD`, and the next `v=ABC` stores
* `abc`. So this stamps and never rewrites. `-l` and `-u` are exclusive:
* setting one clears the other, and a cluster naming both (`-lu`, `-ul`)
* sets neither, both pinned on 5.2.37. A `+` letter clears; `+r` is
* refused earlier on a readonly name and a no-op otherwise, so it is not
* an off toggle. Through the gated mark door for every name, covered or
* not: the handler already cleared the gate for these names, so this is
* one redundant policy call per attribute, and it keeps this stamp out
* of the ungated-write allowlist that `setAttr` sites must justify.
*/
async function stampAttrs(
session: Session,
view: SessionView,
flagChars: ReadonlySet<string>,
plusChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
stored: readonly string[],
): Promise<Result | null> {
const refused = await stampExport(session, view, flagChars, assignments, staged, stored)
if (refused !== null) return refused
let onAttrs = attrsFor('ilunt', (c) => flagChars.has(c) && !plusChars.has(c))
if (flagChars.has('l') && flagChars.has('u')) {
onAttrs = onAttrs.filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper)
}
const offAttrs = attrsFor('iluntx', (c) => plusChars.has(c))
if (onAttrs.length === 0 && offAttrs.length === 0) return null
try {
for (const name of stored) {
for (const attr of onAttrs) {
await view.mark(name, attr, true)
// `-l` displaces `-u` and vice versa; the record keeps one.
if (attr === VarAttr.Lower) await view.mark(name, VarAttr.Upper, false)
else if (attr === VarAttr.Upper) await view.mark(name, VarAttr.Lower, false)
}
for (const attr of offAttrs) await view.mark(name, attr, false)
}
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
const denied = new TextEncoder().encode(`${err.message}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: denied }),
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: denied }),
]
}
return null
}
/**
* Mark every name a `-x` declaration stored as exported.
*
* `declare -x NAME` marks an existing name without touching its value and
* `declare -x NAME=v` assigns then marks, so the stamp lands after the
* assignment either way. Staged array literals are stamped too, since an
* array is as exportable as a scalar: GNU answers `declare -x A=(a b)`
* with `declare -ax A=([0]="a" [1]="b")`, and reading only `assignments`
* left every `declare -x NAME=(...)` unmarked.
*
* Shared by the readonly and the plain declaration branch because
* `declare -rx X=1` goes down the readonly one and still owes the export
* attribute.
*
* Only the names the handler reports storing are marked, and marking is
* not gated on the aggregate status: a declaration keeps its valid
* operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits 1
* and still answers `declare -x GOOD="1"`.
*
* A name that carried a value went through `view.set`, so its mark rides
* on that decision; a bare name did not, and on an *existing* name the
* handler writes nothing at all, so the mark is the only session write
* there is and has to clear `pre_session` itself. Stamping it through
* `setAttr` let `declare -x AWS_TOKEN` export a host-seeded credential
* the deployment had refused.
*/
async function stampExport(
session: Session,
view: SessionView,
flagChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
stored: readonly string[],
): Promise<Result | null> {
if (!flagChars.has('x')) return null
const covered = new Set<string>()
for (const a of assignments) {
const eq = a.indexOf('=')
if (eq >= 0) covered.add(a.slice(0, eq))
}
for (const { name } of staged ?? []) covered.add(name)
for (const name of stored) {
if (covered.has(name)) {
setAttr(session, name, VarAttr.Export)
continue
}
try {
await view.mark(name, VarAttr.Export, true)
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
const encoded = new TextEncoder().encode(`${err.message}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: encoded }),
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: encoded }),
]
}
}
return null
}
/**
* Execute one declaration statement (export/local/declare/readonly).
*
* The executor only reads the operands: it expands them, sorts them
* into option letters, plain names and staged array literals, then
* hands the result to the builtin handler that owns the keyword. The
* attribute letters (`-x`, `-i`, `-l`) are stamped afterwards through
* the same gated door, so `declare -rx X=1` keeps both marks.
*/
export async function executeDeclaration(
node: TSNodeLike,
session: Session,
executeFn: ExecuteFn,
registry: MountRegistry,
namespace: Namespace,
callStack: CallStack | null,
): Promise<Result> {
const keyword = getDeclarationKeyword(node)
const assignments: string[] = []
// Array literals are staged, not stored: `readonly -a a=(y)` on an
// already-readonly name has to fail with the old value intact.
const staged: { name: string; append: boolean; items: string[] }[] = []
// Option words are kept verbatim, in order, so `--` survives as an
// end-of-options marker and the handlers can name the *first* bad option
// letter the way bash does.
const flagWords: string[] = []
const flagChars = new Set<string>()
const plusChars = new Set<string>()
let optsDone = false
for (const child of node.namedChildren) {
if (child.type === NT.VARIABLE_ASSIGNMENT) {
const valNodes = child.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME)
const firstVal = valNodes[0]
if (firstVal?.type === NT.ARRAY) {
const text = getText(child)
const eq = text.indexOf('=')
const key = eq >= 0 ? text.slice(0, eq) : text
const append = key.endsWith('+')
staged.push({
name: append ? key.slice(0, -1) : key,
append,
items: await expandArrayItems(
firstVal,
session,
executeFn,
registry,
namespace,
callStack,
),
})
continue
}
assignments.push(
await expandNode(
child,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
),
)
} else if (
child.type === NT.SIMPLE_EXPANSION ||
child.type === NT.EXPANSION ||
child.type === NT.CONCATENATION ||
child.type === NT.WORD ||
// A bare `readonly NAME` / `export NAME` operand parses as a
// variable_name, not a word, and a quoted assignment
// (`export 'FOO=bar'`) as a plain string operand.
child.type === NT.VARIABLE_NAME ||
child.type === NT.STRING ||
child.type === NT.RAW_STRING ||
child.type === NT.ANSI_C_STRING ||
child.type === NT.TRANSLATED_STRING
) {
const expanded = await expandNode(
child,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
// An *unquoted* expansion that came back empty is removed by
// word splitting, so `export $UNSET` is a bare `export` and
// prints the listing. A quoted one is a real, empty operand:
// GNU answers both `export ""` and `export "$UNSET"` with
// ``export: `': not a valid identifier``, so it has to reach
// the builtin rather than vanish here.
if (expanded === '' && (child.type === NT.SIMPLE_EXPANSION || child.type === NT.EXPANSION))
continue
if (!optsDone && expanded.startsWith('-') && expanded.length > 1) {
flagWords.push(expanded)
if (expanded === '--') optsDone = true
else for (const ch of expanded.slice(1)) flagChars.add(ch)
} else if (
!optsDone &&
expanded.startsWith('+') &&
expanded.length > 1 &&
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
) {
// `+attr` turns an attribute off. Only the declare family
// reads it: `export +x` and `readonly +r` are `not a valid
// identifier` in GNU, so for those two the word falls through
// as an operand and refuses there.
for (const ch of expanded.slice(1)) plusChars.add(ch)
} else {
assignments.push(expanded)
}
}
}
const cmdWord = keyword === NT.LOCAL ? 'local' : keyword
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
const refused = declareOptionRefusal(cmdWord, flagChars, plusChars)
if (refused !== null) return refused
}
if (
(flagChars.has('f') || flagChars.has('F')) &&
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
) {
// `-f`/`-F` select functions, not variables: `-rf` freezes, `-f
// NAME` prints the body, `-F NAME` prints the name, and a missing
// name is exit 1 without a word.
return handleDeclareFunctions(cmdWord, session, flagChars, assignments)
}
const isReadonly = keyword === 'readonly' || flagChars.has('r')
// `-l` and `-u` cannot both hold; a cluster naming both sets neither
// (pinned: `declare -lu s=aBc` prints `declare -- s`).
let shaping = new Set(attrsFor('ilu', (c) => flagChars.has(c) && !plusChars.has(c)))
if (shaping.has(VarAttr.Lower) && shaping.has(VarAttr.Upper)) {
shaping = new Set([...shaping].filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper))
}
const conversionErrors: string[] = []
if (flagChars.has('A') || flagChars.has('a')) {
// `declare -a NAME` / `declare -A NAME` with no value declare an
// empty array of that kind, so ${#NAME[@]} is 0 and an element
// write leaves the other slots unassigned. GNU refuses to
// convert between the two kinds and says so per name while the
// rest of the operands still declare.
const wantAssoc = flagChars.has('A')
for (const bare of assignments) {
if (bare.includes('=')) continue
// Both branches below write array storage raw (the top-level
// one migrates an existing scalar), so a hidden name refuses
// like any assignment spelling before either lands.
try {
ensureVarVisible(session, bare)
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
throw new ExitSignal(1, new TextEncoder().encode(`${err.message}\n`), null, 1)
}
if (wantAssoc && Object.hasOwn(session.arrays, bare)) {
conversionErrors.push(
`bash: ${cmdWord}: ${bare}: cannot convert indexed to associative array`,
)
continue
}
if (!wantAssoc && Object.hasOwn(session.assocs, bare)) {
conversionErrors.push(
`bash: ${cmdWord}: ${bare}: cannot convert associative to indexed array`,
)
continue
}
if (!flagChars.has('g') && noteLocalArray(session, bare)) {
// Inside a function this shadows whatever the caller had with
// a fresh empty array of the declared kind; `-g` declares at
// global scope instead.
seedVar(session, bare, wantAssoc ? {} : [])
} else if (wantAssoc && !Object.hasOwn(session.assocs, bare)) {
// At top level an existing scalar becomes the value at the
// literal key "0" (GNU allows scalar-to-associative
// conversion, unlike indexed).
const scalar = session.env[bare]
seedVar(session, bare, scalar === undefined ? {} : { '0': scalar })
} else if (!wantAssoc && !Object.hasOwn(session.arrays, bare)) {
// At top level an existing scalar becomes element 0.
const scalar = session.env[bare]
seedVar(session, bare, scalar === undefined ? [] : [scalar])
}
}
}
// Array literals travel as data: the handler stores them through
// the session door and owns both refusal voices, so the executor
// only expands and stages.
if (isReadonly) {
// Only the `readonly` keyword owns -p / illegal-option handling;
// `declare -r` keeps names only.
const declView = sessionView(session, registry.policies)
const stored: string[] = []
const result =
keyword === 'readonly'
? await handleReadonly(
[...flagWords, ...assignments],
session,
declView,
staged,
stored,
flagChars.has('A'),
shaping,
)
: await handleReadonly(
assignments,
session,
declView,
staged,
stored,
flagChars.has('A'),
shaping,
)
// `declare -rx X=1` carries both attributes: GNU prints
// `declare -rx X="1"`. Readonly answers first, so the export stamp
// has to land here too, or `-r` silently ate the `-x`.
const refused = await stampAttrs(
session,
declView,
flagChars,
plusChars,
assignments,
staged,
stored,
)
return refused ?? mergeConversionErrors(result, conversionErrors)
}
// declare/typeset scope like `local` inside a function (bash
// semantics) and assign globally at top level, which is exactly
// handleLocal's fallback when no function scope is active.
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
// `-p` prints rather than declares, so it is answered before the
// assignment path runs at all.
if (
(flagChars.has('p') || plusChars.has('p')) &&
(keyword === 'declare' || keyword === 'typeset')
) {
return handleDeclarePrint(assignments, session)
}
const declView2 = sessionView(session, registry.policies)
const stored2: string[] = []
const result = await handleLocal(
assignments,
session,
declView2,
staged,
// `declare`/`typeset` share this handler but have to name
// themselves in a diagnostic rather than say `local`.
cmdWord,
stored2,
flagChars.has('A'),
shaping,
flagChars.has('n') && !plusChars.has('n'),
flagChars.has('g'),
)
const plusRefused = plusRefusals(cmdWord, session, declView2, plusChars, assignments, staged)
if (plusRefused !== null) return plusRefused
const refused2 = await stampAttrs(
session,
declView2,
flagChars,
plusChars,
assignments,
staged,
stored2,
)
return refused2 ?? mergeConversionErrors(result, conversionErrors)
}
// Pass export flags through so -p / bare print and illegal options work.
const exportResult = await handleExport(
[...flagWords, ...assignments],
session,
sessionView(session, registry.policies),
staged,
)
return mergeConversionErrors(exportResult, conversionErrors)
}
@@ -12,7 +12,6 @@
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { deref, seedVar } from '../session/state.ts'
import type { Runtime } from '../../runtime/base.ts'
import type { PolicyDecision } from '../../runtime/policy/index.ts'
import { asyncChain } from '../../io/stream.ts'
@@ -25,7 +24,6 @@ import { assignmentStatus, finishStatement } from '../executor/statement.ts'
import {
getCaseItems,
getCaseWord,
getDeclarationKeyword,
getCforParts,
getForParts,
getFunctionBody,
@@ -46,21 +44,10 @@ import { expandRedirects } from '../expand/redirects.ts'
import { type ExecuteFn, expandArith, expandNode } from '../expand/node.ts'
import { expandPattern } from '../expand/pattern.ts'
import { evaluateArith } from '../../shell/arith.ts'
import {
type ShellArray,
arrayExtent,
arrayGet,
arraySet,
buildAssocLiteral,
buildIndexedLiteral,
} from '../../shell/array.ts'
import { ArithError, ExitSignal, ReadonlyError } from '../../shell/errors.ts'
import { ArithError, ReadonlyError } from '../../shell/errors.ts'
import { expandAndClassify } from '../expand/parts.ts'
import { arrayIndex } from '../expand/variable.ts'
import { assignElement } from '../session/elements.ts'
import type { ArithResult, TSNodeLike } from '../../shell/types.ts'
import { wordText } from '../../types.ts'
import { compareCodePoints } from '../../utils/sort.ts'
import {
type CforEval,
handleCase,
@@ -72,16 +59,7 @@ import {
handleWhile,
} from '../executor/control.ts'
import type { DispatchFn } from '../../runtime/types.ts'
import {
handleExport,
handleDeclareFunctions,
handleDeclarePrint,
handleLocal,
handleReadonly,
handleTest,
handleUnset,
noteLocalArray,
} from '../executor/builtins/index.ts'
import { handleTest, handleUnset } from '../executor/builtins/index.ts'
import { handleConnection, handlePipe, handleSubshell } from '../executor/pipes.ts'
import { handleRedirect } from '../executor/redirect.ts'
import type { Namespace } from '../mount/namespace/namespace.ts'
@@ -93,18 +71,11 @@ import { expandDoubleBracket, expandTestExpr } from './test_expr.ts'
import { executeProgram } from './program.ts'
import { installExecRedirects } from '../executor/builtins/exec_cmd.ts'
import { executeCommand } from './command_dispatch.ts'
import { executeAssignment } from './assignment.ts'
import { executeDeclaration } from './declaration.ts'
import { PolicyDenied } from '../../policy/errors.ts'
import type { SessionView } from '../../ops/types.ts'
import {
elementIndex,
ensureVarVisible,
sessionElements,
sessionView,
setAttr,
visibleEnv,
} from '../session/state.ts'
import { type ShellValue, VarAttr } from '../../shell/variable.ts'
import { traceAssignment } from '../../shell/xtrace.ts'
import { ensureVarVisible, sessionElements, sessionView, visibleEnv } from '../session/state.ts'
import { Channel, type JobConsole } from '../../shell/console/index.ts'
import { type ExecuteNodeOpts, pump } from '../executor/jobs.ts'
@@ -146,32 +117,6 @@ function withOpts(base: ExecuteNodeDeps, opts?: ExecuteNodeOpts): ExecuteNodeDep
return next
}
/**
* One assignment through the session door; denial is fatal.
*
* Every assignment spelling (scalar, array literal, subscript, append)
* computes its resulting value and stores through `view.set`, so the
* gate and the storage invariant live in the door, not here. Denial
* mirrors the readonly case: a fatal variable-assignment error that
* abandons the rest of the line.
*/
async function assignVar(view: SessionView, key: string, value: ShellValue): Promise<void> {
try {
await view.set(key, value)
} catch (err) {
if (err instanceof PolicyDenied) {
const denied = new TextEncoder().encode(`${err.message}\n`)
throw new ExitSignal(1, denied, null, 1)
}
if (err instanceof ArithError) {
// The `-i` coercion refused the text. GNU aborts the line the way
// a bad subscript does, in the evaluator's voice with the text led.
throw new ExitSignal(1, new TextEncoder().encode(`bash: ${err.message}\n`), null, 1)
}
throw err
}
}
/**
* Evaluate one C-style for expression slot: the slot's integer value,
* or the default for an empty slot (1 for the condition so `for
@@ -213,36 +158,6 @@ async function evalCforExpr(
return Number(result.value)
}
// Array-literal elements behave like any other shell word list: command
// substitutions word-split and globs resolve to matches
// (`a=($(cmd) /data/*.txt)`), with zero-match globs kept literal.
async function expandArrayItems(
arrayNode: TSNodeLike,
session: Session,
executeFn: ExecuteFn,
registry: MountRegistry,
namespace: Namespace,
callStack: CallStack | null,
): Promise<string[]> {
const classified = await expandAndClassify(
arrayNode.namedChildren,
session,
executeFn,
registry,
session.cwd,
callStack,
sessionView(session, registry.policies),
)
const resolved = await resolveGlobs(
classified,
registry,
session.shellOptions.noglob === true,
namespace,
globOptions(session),
)
return resolved.map((w) => wordText(w))
}
async function recurseReassociated(
recurse: Recurse,
dispatch: DispatchFn,
@@ -354,288 +269,6 @@ export interface ExecuteNodeDeps {
sink?: JobConsole
}
/**
* Mark every name a `-x` declaration stored as exported.
*
* `declare -x NAME` marks an existing name without touching its value and
* `declare -x NAME=v` assigns then marks, so the stamp lands after the
* assignment either way. Staged array literals are stamped too, since an
* array is as exportable as a scalar: GNU answers `declare -x A=(a b)`
* with `declare -ax A=([0]="a" [1]="b")`, and reading only `assignments`
* left every `declare -x NAME=(...)` unmarked.
*
* Shared by the readonly and the plain declaration branch because
* `declare -rx X=1` goes down the readonly one and still owes the export
* attribute.
*
* Only the names the handler reports storing are marked, and marking is
* not gated on the aggregate status: a declaration keeps its valid
* operands when a sibling refuses, so `declare -x GOOD=1 1BAD=x` exits 1
* and still answers `declare -x GOOD="1"`.
*
* A name that carried a value went through `view.set`, so its mark rides
* on that decision; a bare name did not, and on an *existing* name the
* handler writes nothing at all, so the mark is the only session write
* there is and has to clear `pre_session` itself. Stamping it through
* `setAttr` let `declare -x AWS_TOKEN` export a host-seeded credential
* the deployment had refused.
*/
const SUBSCRIPT_LITERAL_TYPES: ReadonlySet<string> = new Set([NT.WORD, NT.NUMBER, NT.ERROR])
/**
* The expanded subscript text of one `name[...]=` assignment.
*
* A purely literal subscript keeps its raw spelling, spaces included
* (bash stores `m[ k ]` under the key `" k "`); anything carrying an
* expansion or quoting expands node by node so `m[$k]` and `m["a b"]`
* resolve with quote removal. The associative path uses the result as
* the key verbatim; the indexed path evaluates it as arithmetic.
*/
async function subscriptKeyText(
subscriptNode: TSNodeLike,
name: string,
session: Session,
executeFn: ExecuteFn,
callStack: CallStack | null,
view?: SessionView,
): Promise<string> {
const inner = subscriptNode.namedChildren.filter((sc) => sc.type !== NT.VARIABLE_NAME)
const raw = subscriptNode.text.slice(name.length + 1, -1)
if (inner.length === 0 || inner.every((sc) => SUBSCRIPT_LITERAL_TYPES.has(sc.type))) {
return raw
}
const parts: string[] = []
for (const sc of inner) {
parts.push(await expandNode(sc, session, executeFn, callStack, view))
}
return parts.join('')
}
/**
* Fold kind-conversion refusals into a declaration's result.
*
* GNU reports `cannot convert indexed to associative array` per refused
* name on stderr and fails the builtin with 1 while the other operands
* still declare, so the refusals ride the handler's own result rather
* than replacing it.
*/
function mergeConversionErrors(result: Result, errors: readonly string[]): Result {
if (errors.length === 0) return result
const [stream, io, node] = result
const extra = new TextEncoder().encode(errors.join('\n') + '\n')
const prior = io.stderr instanceof Uint8Array ? io.stderr : new Uint8Array(0)
const merged = new Uint8Array(prior.length + extra.length)
merged.set(prior, 0)
merged.set(extra, prior.length)
const newIo = new IOResult({
exitCode: 1,
stderr: merged,
reads: io.reads,
writes: io.writes,
cache: io.cache,
})
return [stream, newIo, new ExecutionNode({ command: node.command, exitCode: 1, stderr: merged })]
}
// Every letter GNU's `declare` accepts, so a typo refuses with the usage
// line instead of being silently dropped. `-a`/`-A` are kinds, not
// attributes, and are handled by the array branch; `-p`/`-f`/`-F`/`-g`
// /`-I` are modes the handlers read. `-n` is accepted and stored, but
// aliasing (reads and writes through the reference) is not wired: it is
// a separate seam through every expansion site, so a name carrying it
// declares and prints, and nothing more, rather than a partial alias
// that works in some spellings and not others.
// `-n` stores the reference and every reader and writer resolves through
// it (`deref` in `session/state`).
const DECLARE_LETTERS: ReadonlySet<string> = new Set('aAfFgiIlnprtux')
const DECLARE_USAGE =
'declare: usage: declare [-aAfFgiIlnrtux] [name[=value] ...] or declare -p [-aAfFilnrtux] [name ...]'
// The stored attributes a `-letter` / `+letter` toggles.
const ATTR_LETTERS: ReadonlyMap<string, VarAttr> = new Map([
['i', VarAttr.Integer],
['l', VarAttr.Lower],
['u', VarAttr.Upper],
['n', VarAttr.Nameref],
['t', VarAttr.Trace],
['x', VarAttr.Export],
['r', VarAttr.Readonly],
])
/** The attributes the given letters name, in the order given, skipping
* letters that name none (kinds and modes are not attributes). */
function attrsFor(letters: string, has: (c: string) => boolean): VarAttr[] {
const out: VarAttr[] = []
for (const c of letters) {
const attr = ATTR_LETTERS.get(c)
if (attr !== undefined && has(c)) out.push(attr)
}
return out
}
/**
* The refusal a `declare` family option cluster earns, if any.
*
* An unknown letter is GNU's `invalid option` plus the usage line, exit
* 2, and it wins over every other check because bash refuses the
* cluster before it looks at a single operand.
*/
function declareOptionRefusal(
cmd: string,
flagChars: ReadonlySet<string>,
plusChars: ReadonlySet<string>,
): Result | null {
const bad = [...flagChars, ...plusChars]
.sort(compareCodePoints)
.find((c) => !DECLARE_LETTERS.has(c))
if (bad === undefined) return null
const sign = flagChars.has(bad) ? '-' : '+'
const err = new TextEncoder().encode(
`bash: ${cmd}: ${sign}${bad}: invalid option\n${DECLARE_USAGE}\n`,
)
return [
null,
new IOResult({ exitCode: 2, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 2, stderr: err }),
]
}
/**
* The per-name refusals a `+letter` earns after the operands are known.
*
* Two letters cannot be taken off. `+r` on a readonly name is
* `declare: R: readonly variable`, exit 1, and the name stays frozen.
* `+a` / `+A` on an array is `cannot destroy array variables in this
* way`, exit 1, since the kind is what the value is, not a mark. Both
* are pinned on 5.2.37 and neither stops the other operands from
* declaring; the first refusal is what the builtin reports.
*/
function plusRefusals(
cmd: string,
session: Session,
view: SessionView,
plusChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
): Result | null {
if (!plusChars.has('r') && !plusChars.has('a') && !plusChars.has('A')) return null
const names = assignments.map((a) => a.split('=')[0] ?? a)
for (const { name } of staged ?? []) names.push(name)
for (const name of names) {
if (plusChars.has('r') && view.isReadonly(name)) {
const err = new TextEncoder().encode(`bash: ${cmd}: ${name}: readonly variable\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
]
}
if (
(plusChars.has('a') && Object.hasOwn(session.arrays, name)) ||
(plusChars.has('A') && Object.hasOwn(session.assocs, name))
) {
const err = new TextEncoder().encode(
`bash: ${cmd}: ${name}: cannot destroy array variables in this way\n`,
)
return [
null,
new IOResult({ exitCode: 1, stderr: err }),
new ExecutionNode({ command: cmd, exitCode: 1, stderr: err }),
]
}
}
return null
}
/**
* Apply every `-attr` / `+attr` letter to the names a declaration
* stored, on top of the export stamp.
*
* The letters that shape a value (`-i -l -u`) are stored as attributes
* and applied by the door on every *later* write, which is GNU's rule:
* `v=MiXeD; declare -l v` keeps `MiXeD`, and the next `v=ABC` stores
* `abc`. So this stamps and never rewrites. `-l` and `-u` are exclusive:
* setting one clears the other, and a cluster naming both (`-lu`, `-ul`)
* sets neither, both pinned on 5.2.37. A `+` letter clears; `+r` is
* refused earlier on a readonly name and a no-op otherwise, so it is not
* an off toggle. Through the gated mark door for every name, covered or
* not: the handler already cleared the gate for these names, so this is
* one redundant policy call per attribute, and it keeps this stamp out
* of the ungated-write allowlist that `setAttr` sites must justify.
*/
async function stampAttrs(
session: Session,
view: SessionView,
flagChars: ReadonlySet<string>,
plusChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
stored: readonly string[],
): Promise<Result | null> {
const refused = await stampExport(session, view, flagChars, assignments, staged, stored)
if (refused !== null) return refused
let onAttrs = attrsFor('ilunt', (c) => flagChars.has(c) && !plusChars.has(c))
if (flagChars.has('l') && flagChars.has('u')) {
onAttrs = onAttrs.filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper)
}
const offAttrs = attrsFor('iluntx', (c) => plusChars.has(c))
if (onAttrs.length === 0 && offAttrs.length === 0) return null
try {
for (const name of stored) {
for (const attr of onAttrs) {
await view.mark(name, attr, true)
// `-l` displaces `-u` and vice versa; the record keeps one.
if (attr === VarAttr.Lower) await view.mark(name, VarAttr.Upper, false)
else if (attr === VarAttr.Upper) await view.mark(name, VarAttr.Lower, false)
}
for (const attr of offAttrs) await view.mark(name, attr, false)
}
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
const denied = new TextEncoder().encode(`${err.message}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: denied }),
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: denied }),
]
}
return null
}
async function stampExport(
session: Session,
view: SessionView,
flagChars: ReadonlySet<string>,
assignments: readonly string[],
staged: readonly { name: string }[] | null,
stored: readonly string[],
): Promise<Result | null> {
if (!flagChars.has('x')) return null
const covered = new Set<string>()
for (const a of assignments) {
const eq = a.indexOf('=')
if (eq >= 0) covered.add(a.slice(0, eq))
}
for (const { name } of staged ?? []) covered.add(name)
for (const name of stored) {
if (covered.has(name)) {
setAttr(session, name, VarAttr.Export)
continue
}
try {
await view.mark(name, VarAttr.Export, true)
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
const encoded = new TextEncoder().encode(`${err.message}\n`)
return [
null,
new IOResult({ exitCode: 1, stderr: encoded }),
new ExecutionNode({ command: 'declare', exitCode: 1, stderr: encoded }),
]
}
}
return null
}
/**
* Whether a redirected statement's command is a bare `exec`: a command
* name and no arguments, so its redirects are the shell's own rather
@@ -1085,260 +718,7 @@ export async function executeNode(
}
if (kind === NodeKind.DECLARATION) {
const keyword = getDeclarationKeyword(node)
const assignments: string[] = []
// Array literals are staged, not stored: `readonly -a a=(y)` on an
// already-readonly name has to fail with the old value intact.
const staged: { name: string; append: boolean; items: string[] }[] = []
// Option words are kept verbatim, in order, so `--` survives as an
// end-of-options marker and the handlers can name the *first* bad option
// letter the way bash does.
const flagWords: string[] = []
const flagChars = new Set<string>()
const plusChars = new Set<string>()
let optsDone = false
for (const child of node.namedChildren) {
if (child.type === NT.VARIABLE_ASSIGNMENT) {
const valNodes = child.namedChildren.filter((c) => c.type !== NT.VARIABLE_NAME)
const firstVal = valNodes[0]
if (firstVal?.type === NT.ARRAY) {
const text = getText(child)
const eq = text.indexOf('=')
const key = eq >= 0 ? text.slice(0, eq) : text
const append = key.endsWith('+')
staged.push({
name: append ? key.slice(0, -1) : key,
append,
items: await expandArrayItems(
firstVal,
session,
executeFn,
registry,
deps.namespace,
callStack,
),
})
continue
}
assignments.push(
await expandNode(
child,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
),
)
} else if (
child.type === NT.SIMPLE_EXPANSION ||
child.type === NT.EXPANSION ||
child.type === NT.CONCATENATION ||
child.type === NT.WORD ||
// A bare `readonly NAME` / `export NAME` operand parses as a
// variable_name, not a word, and a quoted assignment
// (`export 'FOO=bar'`) as a plain string operand.
child.type === NT.VARIABLE_NAME ||
child.type === NT.STRING ||
child.type === NT.RAW_STRING ||
child.type === NT.ANSI_C_STRING ||
child.type === NT.TRANSLATED_STRING
) {
const expanded = await expandNode(
child,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
// An *unquoted* expansion that came back empty is removed by
// word splitting, so `export $UNSET` is a bare `export` and
// prints the listing. A quoted one is a real, empty operand:
// GNU answers both `export ""` and `export "$UNSET"` with
// ``export: `': not a valid identifier``, so it has to reach
// the builtin rather than vanish here.
if (expanded === '' && (child.type === NT.SIMPLE_EXPANSION || child.type === NT.EXPANSION))
continue
if (!optsDone && expanded.startsWith('-') && expanded.length > 1) {
flagWords.push(expanded)
if (expanded === '--') optsDone = true
else for (const ch of expanded.slice(1)) flagChars.add(ch)
} else if (
!optsDone &&
expanded.startsWith('+') &&
expanded.length > 1 &&
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
) {
// `+attr` turns an attribute off. Only the declare family
// reads it: `export +x` and `readonly +r` are `not a valid
// identifier` in GNU, so for those two the word falls through
// as an operand and refuses there.
for (const ch of expanded.slice(1)) plusChars.add(ch)
} else {
assignments.push(expanded)
}
}
}
const cmdWord = keyword === NT.LOCAL ? 'local' : keyword
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
const refused = declareOptionRefusal(cmdWord, flagChars, plusChars)
if (refused !== null) return refused
}
if (
(flagChars.has('f') || flagChars.has('F')) &&
(keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset')
) {
// `-f`/`-F` select functions, not variables: `-rf` freezes, `-f
// NAME` prints the body, `-F NAME` prints the name, and a missing
// name is exit 1 without a word.
return handleDeclareFunctions(cmdWord, session, flagChars, assignments)
}
const isReadonly = keyword === 'readonly' || flagChars.has('r')
// `-l` and `-u` cannot both hold; a cluster naming both sets neither
// (pinned: `declare -lu s=aBc` prints `declare -- s`).
let shaping = new Set(attrsFor('ilu', (c) => flagChars.has(c) && !plusChars.has(c)))
if (shaping.has(VarAttr.Lower) && shaping.has(VarAttr.Upper)) {
shaping = new Set([...shaping].filter((a) => a !== VarAttr.Lower && a !== VarAttr.Upper))
}
const conversionErrors: string[] = []
if (flagChars.has('A') || flagChars.has('a')) {
// `declare -a NAME` / `declare -A NAME` with no value declare an
// empty array of that kind, so ${#NAME[@]} is 0 and an element
// write leaves the other slots unassigned. GNU refuses to
// convert between the two kinds and says so per name while the
// rest of the operands still declare.
const wantAssoc = flagChars.has('A')
for (const bare of assignments) {
if (bare.includes('=')) continue
// Both branches below write array storage raw (the top-level
// one migrates an existing scalar), so a hidden name refuses
// like any assignment spelling before either lands.
try {
ensureVarVisible(session, bare)
} catch (err) {
if (!(err instanceof PolicyDenied)) throw err
throw new ExitSignal(1, new TextEncoder().encode(`${err.message}\n`), null, 1)
}
if (wantAssoc && Object.hasOwn(session.arrays, bare)) {
conversionErrors.push(
`bash: ${cmdWord}: ${bare}: cannot convert indexed to associative array`,
)
continue
}
if (!wantAssoc && Object.hasOwn(session.assocs, bare)) {
conversionErrors.push(
`bash: ${cmdWord}: ${bare}: cannot convert associative to indexed array`,
)
continue
}
if (!flagChars.has('g') && noteLocalArray(session, bare)) {
// Inside a function this shadows whatever the caller had with
// a fresh empty array of the declared kind; `-g` declares at
// global scope instead.
seedVar(session, bare, wantAssoc ? {} : [])
} else if (wantAssoc && !Object.hasOwn(session.assocs, bare)) {
// At top level an existing scalar becomes the value at the
// literal key "0" (GNU allows scalar-to-associative
// conversion, unlike indexed).
const scalar = session.env[bare]
seedVar(session, bare, scalar === undefined ? {} : { '0': scalar })
} else if (!wantAssoc && !Object.hasOwn(session.arrays, bare)) {
// At top level an existing scalar becomes element 0.
const scalar = session.env[bare]
seedVar(session, bare, scalar === undefined ? [] : [scalar])
}
}
}
// Array literals travel as data: the handler stores them through
// the session door and owns both refusal voices, so the executor
// only expands and stages.
if (isReadonly) {
// Only the `readonly` keyword owns -p / illegal-option handling;
// `declare -r` keeps names only.
const declView = sessionView(session, registry.policies)
const stored: string[] = []
const result =
keyword === 'readonly'
? await handleReadonly(
[...flagWords, ...assignments],
session,
declView,
staged,
stored,
flagChars.has('A'),
shaping,
)
: await handleReadonly(
assignments,
session,
declView,
staged,
stored,
flagChars.has('A'),
shaping,
)
// `declare -rx X=1` carries both attributes: GNU prints
// `declare -rx X="1"`. Readonly answers first, so the export stamp
// has to land here too, or `-r` silently ate the `-x`.
const refused = await stampAttrs(
session,
declView,
flagChars,
plusChars,
assignments,
staged,
stored,
)
return refused ?? mergeConversionErrors(result, conversionErrors)
}
// declare/typeset scope like `local` inside a function (bash
// semantics) and assign globally at top level, which is exactly
// handleLocal's fallback when no function scope is active.
if (keyword === NT.LOCAL || keyword === 'declare' || keyword === 'typeset') {
// `-p` prints rather than declares, so it is answered before the
// assignment path runs at all.
if (
(flagChars.has('p') || plusChars.has('p')) &&
(keyword === 'declare' || keyword === 'typeset')
) {
return handleDeclarePrint(assignments, session)
}
const declView2 = sessionView(session, registry.policies)
const stored2: string[] = []
const result = await handleLocal(
assignments,
session,
declView2,
staged,
// `declare`/`typeset` share this handler but have to name
// themselves in a diagnostic rather than say `local`.
cmdWord,
stored2,
flagChars.has('A'),
shaping,
flagChars.has('n') && !plusChars.has('n'),
flagChars.has('g'),
)
const plusRefused = plusRefusals(cmdWord, session, declView2, plusChars, assignments, staged)
if (plusRefused !== null) return plusRefused
const refused2 = await stampAttrs(
session,
declView2,
flagChars,
plusChars,
assignments,
staged,
stored2,
)
return refused2 ?? mergeConversionErrors(result, conversionErrors)
}
// Pass export flags through so -p / bare print and illegal options work.
const exportResult = await handleExport(
[...flagWords, ...assignments],
session,
sessionView(session, registry.policies),
staged,
)
return mergeConversionErrors(exportResult, conversionErrors)
return await executeDeclaration(node, session, executeFn, registry, deps.namespace, callStack)
}
if (kind === NodeKind.UNSET) {
@@ -1386,205 +766,7 @@ export async function executeNode(
}
if (kind === NodeKind.VAR_ASSIGN) {
const text = getText(node)
if (!text.includes('=')) {
return [null, new IOResult(), new ExecutionNode({ command: text, exitCode: 0 })]
}
const subSeq = session.cmdsubSeq
const subscriptNode = node.namedChildren.find((c) => c.type === 'subscript') ?? null
const nameSource = subscriptNode ?? node
const nameNode = nameSource.namedChildren.find((c) => c.type === NT.VARIABLE_NAME)
const eq = text.indexOf('=')
const spelled = nameNode !== undefined ? nameNode.text : text.slice(0, eq)
// A name reference assigns to its target, whatever the shape of the
// assignment; an unaimed one (`declare -n r; r=v`) resolves to itself
// and takes the value as the target's name. The spelling is kept for
// slicing the subscript out of the source.
const key = deref(session, spelled) || spelled
const append = node.children.some((c) => c.type === '+=')
if (session.readonlyVars.has(key)) {
// A bare assignment to a readonly variable is a fatal
// variable-assignment error in non-interactive bash: the rest of
// the line is abandoned (builtins like `export` merely fail with
// 1 and continue).
const err = new TextEncoder().encode(`bash: ${key}: readonly variable\n`)
throw new ExitSignal(1, err, null, 1)
}
const valNodes = node.namedChildren.filter(
(c) => c.type !== NT.VARIABLE_NAME && c.type !== 'subscript',
)
// Every branch below computes its resulting value with bash's own
// mechanics on a copy, then stores through the one session door,
// which owns the gate and the scalar/array invariant.
const view = sessionView(session, registry.policies)
const firstVal = valNodes[0]
if (firstVal?.type === NT.ARRAY) {
const items = await expandArrayItems(
firstVal,
session,
executeFn,
registry,
deps.namespace,
callStack,
)
const heldMap = session.assocs[key]
if (heldMap !== undefined) {
const { map, badWords } = buildAssocLiteral(heldMap, items, append)
await assignVar(view, key, map)
if (badWords.length > 0) {
const errBytes = new TextEncoder().encode(
badWords
.map(
(word) =>
`bash: ${key}: '${word}': must use subscript when assigning associative array`,
)
.join('\n') + '\n',
)
return [
null,
new IOResult({ exitCode: 1, stderr: errBytes }),
new ExecutionNode({ command: text, exitCode: 1, stderr: errBytes }),
]
}
const mapCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: mapCode }),
new ExecutionNode({ command: text, exitCode: mapCode }),
]
}
let held: ShellArray | null = session.arrays[key] ?? null
if (append && held === null) {
const scalar = session.env[key]
held = scalar === undefined ? null : [scalar]
}
// `arr+=(...)` starts at the extent, so it fills the hole a
// trailing `unset arr[last]` left but skips interior ones; a
// `[i]=v` element places at i and the next plain word continues
// from there.
const base = buildIndexedLiteral(held, items, append, (sub) =>
elementIndex(sub, visibleEnv(session), sessionElements(session)),
)
await assignVar(view, key, base)
const arrCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: arrCode }),
new ExecutionNode({ command: text, exitCode: arrCode }),
]
}
let val = text.slice(eq + 1)
if (firstVal !== undefined) {
val = await expandNode(
firstVal,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
}
if (subscriptNode !== null) {
const subText = await subscriptKeyText(
subscriptNode,
spelled,
session,
executeFn,
callStack,
sessionView(session, registry.policies),
)
const heldMap = session.assocs[key]
const rawSub = subscriptNode.text.slice(spelled.length + 1, -1)
if (rawSub.trim() === '' || (heldMap !== undefined && subText === '')) {
// bash aborts the whole line on a bad assignment subscript
// (status 1), naming the raw spelling (`m[$e]: bad array
// subscript`). An indexed subscript that merely *expands*
// empty stays legal (arithmetic on nothing is 0), so only the
// associative kind checks the expanded text.
const nameText = text.slice(0, eq).replace(/\+$/, '')
throw new ExitSignal(
1,
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
null,
1,
)
}
if (heldMap !== undefined) {
// The subscript is the key: no arithmetic, `m[1+1]` writes the
// key "1+1".
const newMap = { ...heldMap }
newMap[subText] = append ? (heldMap[subText] ?? '') + val : val
await assignVar(view, key, newMap)
const mapCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: mapCode }),
new ExecutionNode({ command: text, exitCode: mapCode }),
]
}
const existing = session.arrays[key]
let arr: ShellArray
if (existing === undefined) {
const scalar = session.env[key]
arr = scalar === undefined ? [] : [scalar]
} else {
arr = [...existing]
}
let idx = arrayIndex(subText, visibleEnv(session), sessionElements(session))
if (idx < 0) idx += arrayExtent(arr)
if (idx < 0) {
// Same fatal shape as the empty subscript above.
const nameText = text.slice(0, eq).replace(/\+$/, '')
throw new ExitSignal(
1,
new TextEncoder().encode(`bash: ${nameText}: bad array subscript\n`),
null,
1,
)
}
arraySet(arr, idx, append ? arrayGet(arr, idx) + val : val)
await assignVar(view, key, arr)
const subCode = assignmentStatus(session, subSeq)
return [
null,
new IOResult({ exitCode: subCode }),
new ExecutionNode({ command: text, exitCode: subCode }),
]
}
const heldMap = session.assocs[key]
const heldArr = session.arrays[key]
if (heldMap !== undefined) {
// `m=x` on an associative array writes the literal key "0" and
// keeps every other key, as bash does.
const newMap = { ...heldMap }
newMap['0'] = append ? (heldMap['0'] ?? '') + val : val
await assignVar(view, key, newMap)
} else if (heldArr !== undefined) {
// `a=x` writes element 0 and keeps the rest; `a+=x` appends onto
// element 0.
const newArr = [...heldArr]
arraySet(newArr, 0, append ? arrayGet(newArr, 0) + val : val)
await assignVar(view, key, newArr)
} else {
const heldVar = session.vars[key]
let newVal: string
if (append && heldVar?.attrs.has(VarAttr.Integer) === true) {
// `n+=3` on an integer name adds: the door evaluates `old + new`,
// so `declare -i n=5; n+=3` stores 8, not 53.
newVal = `${session.env[key] ?? '0'} + (${val})`
} else {
newVal = append ? (session.env[key] ?? '') + val : val
}
await assignVar(view, key, newVal)
}
// Reassigning OPTIND (even to its current value) restarts the getopts
// scan, matching bash's internal char pointer.
if (key === 'OPTIND') session.getoptsOptind = null
const code = assignmentStatus(session, subSeq)
const assignIo = new IOResult({ exitCode: code })
if (session.shellOptions.xtrace === true) {
assignIo.stderr = traceAssignment(key, val, append)
}
return [null, assignIo, new ExecutionNode({ command: text, exitCode: code })]
return await executeAssignment(node, session, executeFn, registry, deps.namespace, callStack)
}
// Assignment-only statement (a=1 b=2).
@@ -42,7 +42,7 @@ const ALLOWED: Record<string, string> = {
'workspace/executor/builtins/vars.ts::handleReadonly':
'the `=` branch only; `await view.set(key, val)` runs first and ' +
'the bare form uses `view.mark`',
'workspace/node/execute_node.ts::stampExport':
'workspace/node/declaration.ts::stampExport':
'the `covered` branch only, which is the names that carried a ' +
'value or a staged array literal; a bare name has no gated write ' +
'to ride on and goes through `view.mark`',