Files
Zecheng Zhang 2c6af5e43e fix(shell): model redirect opens in order, and two attribute gaps
Each open is visible to the next, so `set -C; echo x > a > a` creates
`a` on the first redirect and refuses the second. Probing every target
against one pre-command snapshot passed both and wrote the output. The
opens a refused statement already performed are applied, since bash
leaves the file existing and empty.

`declare -rx X=1` carries both attributes. Readonly answers first, so
the export stamp moved into a helper both branches call.

`export -n ARR=(b)` takes the attribute off an array. The store keeps
whatever the name carried, so an unapplied mark left it exported.

`set -v` echoes comments and blank lines, which the reader consumes
like any other line.
2026-08-15 21:47:49 -07:00

214 lines
9.3 KiB
Python

# ========= 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 pytest
from mirage.shell.variable import VarAttr
from mirage.workspace.session.session import Session
from mirage.workspace.session.state import env_snapshot
# Every case pinned against GNU bash 5.2.37 on debian:stable-slim.
# The environment is the *exported* set, not every string-valued
# variable, which is the whole subject of this file.
ENV_CASES = [
# A plain assignment is a shell variable and never reaches `env`.
('X=hello; export Y=world; env | grep -E "^(X|Y)="', "Y=world\n"),
# A name marked but never assigned is not in the environment either:
# `export Z` declares, it does not give Z a value.
('export Z; env | grep -c "^Z="', "0\n"),
# `export -n` keeps the value and drops the name from the env.
('export Y=world; export -n Y; env | grep -c "^Y="', "0\n"),
# `set -a` marks what is assigned *while it is on*, and nothing else.
("B=1; set -a; C=2; set +a; D=3;"
' env | grep -cE "^(B|C|D)="', "1\n"),
("set -a; A=auto; env | grep '^A='", "A=auto\n"),
]
DECLARE_CASES = [
# `--` for a plain scalar, `-x` once exported.
("X=hello; export Y=world; declare -p X Y",
'declare -- X="hello"\ndeclare -x Y="world"\n'),
# The declared-but-unset third state prints bare, with no `=`.
("export Z; declare -p Z", "declare -x Z\n"),
# `declare -x` on an existing name keeps the value and adds the mark.
("E=val; declare -x E; declare -p E", 'declare -x E="val"\n'),
# `export -n` is the off direction and leaves an ordinary variable.
("export Y=world; export -n Y; declare -p Y", 'declare -- Y="world"\n'),
# Never exported, so `-n` is a no-op rather than an error.
("P=plain; export -n P; echo rc=$?; declare -p P",
'rc=0\ndeclare -- P="plain"\n'),
# Two attributes print in bash's own order (`r` before `x`), which
# is `attr_letters`' order and not the order they were set in.
("export RO=v; readonly RO; declare -p RO", 'declare -rx RO="v"\n'),
# `set -a` again, read through declare rather than env.
("B=1; set -a; C=2; set +a; D=3; declare -p B C D",
'declare -- B="1"\ndeclare -x C="2"\ndeclare -- D="3"\n'),
]
EXPORT_P_CASES = [
# Only exported names, and the unset one prints without a value.
# Grepped to the three names under test because a real shell (and a
# mirage session, which exports PWD) carries plenty of others.
("X=hello; export Y=world; export Z;"
' export -p | grep -E "^declare -x (X|Y|Z)"',
'declare -x Y="world"\ndeclare -x Z\n'),
# `export -n` removes it from the listing entirely.
('export Q; export -n Q; export -p | grep -c "declare -x Q"', "0\n"),
]
@pytest.mark.parametrize("cmd,out", ENV_CASES)
def test_env_carries_only_exported_names(shell, cmd, out):
assert shell.mirage(cmd) == out
@pytest.mark.parametrize("cmd,out", DECLARE_CASES)
def test_declare_p_renders_the_attributes(shell, cmd, out):
assert shell.mirage(cmd) == out
@pytest.mark.parametrize("cmd,out", EXPORT_P_CASES)
def test_export_p_lists_the_exported_set(shell, cmd, out):
assert shell.mirage(cmd) == out
def test_printenv_is_a_process_view(shell):
# printenv is a separate binary in GNU, so the only names it can
# possibly see are exported ones; a plain variable exits 1.
assert shell.mirage_result("X=plain; printenv X") == (1, "", "")
assert shell.mirage("export X=e; printenv X") == "e\n"
def test_declare_p_reports_an_unknown_name(shell):
# GNU prints the names it knows, refuses only the ones it does not,
# and exits 1. Deliberate divergence: mirage's diagnostic carries no
# `line N:` (GNU says `bash: line 1: declare: NOPE: not found`),
# matching how every other mirage builtin words its errors.
rc, out, err = shell.mirage_result("G=good; declare -p G NOPE")
assert rc == 1
assert out == 'declare -- G="good"\n'
assert err == "bash: declare: NOPE: not found\n"
def test_pwd_is_exported_from_startup(shell):
# bash exports $PWD, so a session that has never run `cd` still
# hands one to a child.
assert "PWD=" in shell.mirage("env")
def test_cd_exports_pwd_and_oldpwd(shell):
# GNU prints `declare -x OLDPWD` too, and carries both into a child.
# $OLDPWD is created by the first `cd`, so it is marked there; $PWD
# keeps the mark it was seeded with because `seed_var` replaces the
# value rather than the record.
out = shell.mirage("mkdir -p /data/d; cd /data/d; cd /data;"
" declare -p PWD OLDPWD")
assert out == ('declare -x PWD="/data"\n'
'declare -x OLDPWD="/data/d"\n')
env = shell.mirage('env | grep -E "^(PWD|OLDPWD)=" | sort')
assert env == "OLDPWD=/data/d\nPWD=/data\n"
def test_fork_keeps_pwd_exported():
# `fork(cwd=...)` rebuilds $PWD to name where the fork is, and has to
# rebuild the attribute with it: a fresh record would drop the mark
# and the forked session's env would lose PWD entirely.
session = Session(session_id="s1", cwd="/")
forked = session.fork(cwd="/data")
assert VarAttr.EXPORT in forked.vars["PWD"].attrs
assert env_snapshot(forked)["PWD"] == "/data"
def test_a_plain_variable_still_expands_and_lists(shell):
# The narrowing is the *process* view only: `$X` and the bare `set`
# listing are the shell's own view and see every variable.
assert shell.mirage("X=hello; echo $X") == "hello\n"
assert "X=hello\n" in shell.mirage("X=hello; set")
# `export -p` prints the whole cluster, not just `-x`, and an array is
# exportable like anything else. Pinned on bash 5.2.37.
ARRAY_EXPORT_CASES = [
("export ARR=(a b); declare -p ARR",
'declare -ax ARR=([0]="a" [1]="b")\n'),
("declare -x ARR=(a b); declare -p ARR",
'declare -ax ARR=([0]="a" [1]="b")\n'),
("ARR=(a b); export ARR; declare -p ARR",
'declare -ax ARR=([0]="a" [1]="b")\n'),
("readonly R=1; export R; export -p | grep ' R='", 'declare -rx R="1"\n'),
("export ARR=(a b); export -p | grep ARR",
'declare -ax ARR=([0]="a" [1]="b")\n'),
# `-n` is the off direction for an array literal too. The store keeps
# whatever attributes the name already carried, so an unapplied mark
# left the array exported and GNU's `declare -a` came out
# `declare -ax`.
("declare -x ARR=(a); export -n ARR=(b); declare -p ARR",
'declare -a ARR=([0]="b")\n'),
# Both attributes at once: readonly answers first and still owes the
# export mark.
('declare -rx X=1; declare -p X', 'declare -rx X="1"\n'),
]
@pytest.mark.parametrize("cmd,want", ARRAY_EXPORT_CASES)
def test_export_marks_and_renders_arrays(shell, cmd, want):
assert shell.mirage(cmd) == want
def test_an_exported_array_stays_out_of_the_process_view(shell):
# Marked, listed by `export -p`, and still absent from `env`: bash
# puts no array in a child's environment.
assert shell.mirage("export ARR=(a b); env | grep -c ARR || true") == "0\n"
def test_a_bare_local_declares_without_assigning(shell):
# The same third state `export Z` has: declared, unset, so `${L-d}`
# still expands to `d` and `declare -p` prints no `=`. Writing `""`
# here was the invented-empty-string bug the mark door exists to fix.
assert shell.mirage('f() { local L; echo "[${L-UNSET}]"; }; f') == (
"[UNSET]\n")
assert shell.mirage("f() { local L; declare -p L; }; f") == (
"declare -- L\n")
assert shell.mirage("declare D; declare -p D") == "declare -- D\n"
# An explicit empty value is a value, and prints as one.
assert shell.mirage('f() { local L=; echo "[${L-UNSET}]"; }; f') == "[]\n"
def test_local_outside_a_function_is_refused(shell):
_, out, err = shell.mirage_result("local x=1; echo rc=$?")
assert err == "bash: local: can only be used in a function\n"
assert out == "rc=1\n"
# `declare` is the spelling that is legal at top level.
assert shell.mirage("declare x=1; declare -p x") == 'declare -- x="1"\n'
# A prefix assignment goes in the *command's* environment, which is the
# whole point of the form. Pinned on bash 5.2.37.
PREFIX_CASES = [
("TOKEN=x printenv TOKEN", "x\n"),
("TOKEN=x env | grep '^TOKEN='", "TOKEN=x\n"),
# And only for that command: the shell is unchanged afterwards.
("TOKEN=x true; echo \"[${TOKEN-unset}]\"", "[unset]\n"),
# A plain assignment is still a shell variable, not an exported one.
("T2=y; env | grep -c '^T2=' || true", "0\n"),
]
@pytest.mark.parametrize("cmd,want", PREFIX_CASES)
def test_a_prefix_assignment_reaches_the_command_environment(shell, cmd, want):
# `env_snapshot` narrowing to the exported set is what broke this:
# the prefix was seeded plain, so the command, an installed CLI and a
# guest runtime all stopped seeing it.
assert shell.mirage(cmd) == want